Compare commits

...

9 Commits

Author SHA1 Message Date
beo3000 8d2222de8f select Person and Quick-Assign expenses 2025-12-25 21:36:43 +01:00
beo3000 033a3ebd4e mark phase G8 complete 2025-12-25 21:17:42 +01:00
beo3000 0911236a0d feat(G8): add invite link handling for club membership 2025-12-25 21:17:00 +01:00
beo3000 637a3b120c mark phase G7 complete 2025-12-25 21:08:06 +01:00
beo3000 97ea3b35ca feat(G7): add JoinClub page and no-club dashboard message 2025-12-25 21:07:40 +01:00
beo3000 e8b2154f69 mark phase G6 complete
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:59:51 +01:00
beo3000 e62bea77d8 feat(G6): add pending memberships tab to Admin Users
- Add tabs: "Alle Benutzer" + "Ausstehende Anträge"
- Show pending membership requests with approve/reject buttons
- Add RejectMembershipDialog with optional rejection reason

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:59:27 +01:00
beo3000 c48a518dda mark phase G5 complete
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:54:04 +01:00
beo3000 ec50e971a9 feat(G5): add PendingMembershipsWidget
- new PendingMembershipsWidget.razor shows pending membership count
- only visible to club admins/super admins
- integrated into Dashboard.razor
- click navigates to admin users page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:53:41 +01:00
15 changed files with 963 additions and 83 deletions

View File

@ -357,10 +357,10 @@ NavMenu.razor:
| ✓ | G2 | Erweiterte Reg. | ClubInvitation Entity | 1 Entity + Migration |
| ✓ | G3 | Erweiterte Reg. | IEmailService (Stub) | 2 Dateien |
| ✓ | G4 | Erweiterte Reg. | Services erweitern | 2 Services |
| | G5 | Erweiterte Reg. | Dashboard Pending-Widget | 1 Component |
| | G6 | Erweiterte Reg. | Admin Users Page erweitern | 1 Razor |
| | G7 | Erweiterte Reg. | Club-Beitritt UI | 1 Razor |
| | G8 | Erweiterte Reg. | Einladungslink-Handling | 2 Dateien |
| | G5 | Erweiterte Reg. | Dashboard Pending-Widget | 1 Component |
| | G6 | Erweiterte Reg. | Admin Users Page erweitern | 1 Razor |
| | G7 | Erweiterte Reg. | Club-Beitritt UI | 1 Razor |
| | G8 | Erweiterte Reg. | Einladungslink-Handling | 2 Dateien |
**Legende:** ☐ = Offen | ☑ = In Arbeit | ✓ = Fertig

View File

@ -1,7 +1,7 @@
## Optimierung Spieltag-Details
Die Erfassung von Strafen muss möglichst schnell und komfortabel mögiich sein. Dafür muss die Seite DayDetails opimiert werden.
Es soll möglich sein eine Person in der Teilnehmerliste auszuwählen. Bei der Erfassung einer neuen Strafe kann diese Person direkt vorbelegt werden. Außerdem soll zusätzlich zum Button "Strafe hinzufügen" ein Menü-Button angezeigt werden, der eine Kurzwahl aller Strafen mit der Option "IsOneClick" ermöglicht.
Die Erfassung von Strafen muss möglichst schnell und komfortabel mögiich sein. Dafür muss die Seite DayDetails optimiert werden.
Es soll möglich sein eine Person in der Teilnehmerliste auszuwählen. Bei der Erfassung einer neuen Strafe soll diese Person direkt vorbelegt werden. Außerdem soll zusätzlich zum Button "Strafe hinzufügen" ein Menü-Button angezeigt werden, der eine Kurzwahl aller Strafen mit der Option "IsOneClick" ermöglicht.
Der Benutzer soll visuell leicht erkennen können, ob und welcher Teilnehmer aktuell markiert wurde.

View File

@ -71,6 +71,11 @@ namespace Koogle.Application.DTOs
/// Optional club name to assign user to during registration.
/// </summary>
public string? ClubName { get; set; }
/// <summary>
/// Optional invitation token for club membership during registration.
/// </summary>
public string? InviteToken { get; set; }
}
/// <summary>

View File

@ -0,0 +1,231 @@
@page "/account/join-club"
@attribute [Authorize]
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Microsoft.AspNetCore.Authorization
@inject IUserService UserService
@inject IClubService ClubService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
<PageTitle>Club beitreten</PageTitle>
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-4">
<MudText Typo="Typo.h4" Class="mb-4">Club beitreten</MudText>
@if (_isLoading)
{
<MudProgressCircular Indeterminate="true" />
}
else if (_requestSent)
{
<MudAlert Severity="Severity.Success" Class="mb-4">
<MudText Typo="Typo.body1">
Dein Beitrittsantrag wurde erfolgreich gesendet!
</MudText>
<MudText Typo="Typo.body2" Class="mt-2">
Ein Admin des Clubs wird deinen Antrag pruefen. Du erhaeltst eine Benachrichtigung, sobald dein Antrag bearbeitet wurde.
</MudText>
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ResetForm">
Weiteren Antrag stellen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" Class="ml-2" Href="/account/profile">
Zum Profil
</MudButton>
}
else
{
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.body1" Class="mb-4">
Gib den Namen des Clubs ein, dem du beitreten moechtest. Dein Antrag wird vom Club-Admin geprueft.
</MudText>
<MudForm @ref="_form" @bind-IsValid="_isValid">
<MudTextField @bind-Value="_clubName"
Label="Club-Name"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib einen Club-Namen ein"
Immediate="true"
OnKeyUp="OnClubNameKeyUp"
Class="mb-3" />
@if (_searchResult is not null)
{
<MudAlert Severity="Severity.Info" Class="mb-3">
<div class="d-flex justify-space-between align-center">
<div>
<MudText Typo="Typo.body1"><strong>@_searchResult.Name</strong></MudText>
<MudText Typo="Typo.caption">@_searchResult.MemberCount Mitglieder</MudText>
</div>
<MudIcon Icon="@Icons.Material.Filled.Check" Color="Color.Success" />
</div>
</MudAlert>
}
else if (_searched && !string.IsNullOrWhiteSpace(_clubName))
{
<MudAlert Severity="Severity.Warning" Class="mb-3">
Kein Club mit diesem Namen gefunden. Bitte pruefe die Schreibweise.
</MudAlert>
}
@if (!string.IsNullOrEmpty(_error))
{
<MudAlert Severity="Severity.Error" Class="mb-3">@_error</MudAlert>
}
<div class="d-flex gap-2">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="SearchClub"
Disabled="_isSearching || string.IsNullOrWhiteSpace(_clubName)">
@if (_isSearching)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Club suchen
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitRequest"
Disabled="_isSubmitting || _searchResult is null">
@if (_isSubmitting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Beitrittsantrag senden
</MudButton>
</div>
</MudForm>
</MudPaper>
}
</MudContainer>
@code {
private MudForm? _form;
private bool _isValid;
private string _clubName = "";
private ClubDto? _searchResult;
private bool _searched;
private bool _isLoading;
private bool _isSearching;
private bool _isSubmitting;
private bool _requestSent;
private string? _error;
private UserDto? _currentUser;
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
_currentUser = await UserService.GetCurrentUserAsync();
}
finally
{
_isLoading = false;
}
}
private async Task OnClubNameKeyUp(KeyboardEventArgs e)
{
// Reset search result when user types
if (_searched)
{
_searched = false;
_searchResult = null;
_error = null;
}
// Search on Enter key
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_clubName))
{
await SearchClub();
}
}
private async Task SearchClub()
{
if (string.IsNullOrWhiteSpace(_clubName))
return;
_isSearching = true;
_error = null;
_searched = false;
_searchResult = null;
try
{
_searchResult = await ClubService.GetByNameAsync(_clubName.Trim());
_searched = true;
// Check if user is already member
if (_searchResult is not null && _currentUser is not null)
{
var existingMembership = _currentUser.ClubMemberships
.FirstOrDefault(m => m.ClubId == _searchResult.Id);
if (existingMembership is not null)
{
_error = "Du bist bereits Mitglied dieses Clubs oder hast einen offenen Antrag.";
_searchResult = null;
}
}
}
catch (Exception ex)
{
_error = $"Fehler bei der Suche: {ex.Message}";
}
finally
{
_isSearching = false;
}
}
private async Task SubmitRequest()
{
if (_searchResult is null || _currentUser is null)
return;
_isSubmitting = true;
_error = null;
try
{
var success = await UserService.RequestClubMembershipAsync(
_currentUser.ProfileId,
_searchResult.Id);
if (success)
{
_requestSent = true;
Snackbar.Add("Beitrittsantrag erfolgreich gesendet", Severity.Success);
}
else
{
_error = "Der Antrag konnte nicht gesendet werden. Moeglicherweise existiert bereits ein Antrag.";
}
}
catch (Exception ex)
{
_error = $"Fehler beim Senden des Antrags: {ex.Message}";
}
finally
{
_isSubmitting = false;
}
}
private void ResetForm()
{
_clubName = "";
_searchResult = null;
_searched = false;
_requestSent = false;
_error = null;
}
}

View File

@ -14,6 +14,12 @@
<input type="hidden" name="__RequestVerificationToken" value="@_antiToken" />
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
@if (!string.IsNullOrWhiteSpace(_inviteToken))
{
<MudAlert Severity="Severity.Info" Class="mb-3">
Bitte melde dich an, um die Club-Einladung anzunehmen.
</MudAlert>
}
@if (_registered)
{
<MudAlert Severity="Severity.Success" Class="mb-3">Registrierung erfolgreich! Bitte anmelden.</MudAlert>
@ -86,6 +92,7 @@
private string? _error;
private string _antiToken = "";
private bool _registered;
private string? _inviteToken;
protected override void OnInitialized()
{
@ -101,6 +108,12 @@
{
_registered = true;
}
if (query.TryGetValue("invite", out var invite))
{
_inviteToken = invite.ToString();
// After login, redirect to join by invite
_returnUrl = $"/club/join/{_inviteToken}";
}
// Antiforgery Token generieren (klassischer MVC Token)
var http = HttpContextAccessor.HttpContext!;

View File

@ -9,8 +9,18 @@
<form method="post" action="/auth/register">
<!-- Hidden Fields -->
<input type="hidden" name="__RequestVerificationToken" value="@_antiToken" />
@if (!string.IsNullOrWhiteSpace(_inviteToken))
{
<input type="hidden" name="InviteToken" value="@_inviteToken" />
}
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
@if (!string.IsNullOrWhiteSpace(_inviteToken))
{
<MudAlert Severity="Severity.Info" Class="mb-3">
Du wurdest zu einem Club eingeladen. Registriere dich, um beizutreten.
</MudAlert>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<MudAlert Severity="Severity.Error" Class="mb-3">@_error</MudAlert>
@ -75,6 +85,7 @@
@code {
private string? _error;
private string? _inviteToken;
private string _antiToken = "";
protected override void OnInitialized()
@ -87,6 +98,11 @@
_error = MapErrorCodes(err.ToString());
}
if (query.TryGetValue("invite", out var invite))
{
_inviteToken = invite.ToString();
}
// Antiforgery Token generieren
var http = HttpContextAccessor.HttpContext!;
var tokens = Antiforgery.GetAndStoreTokens(http);

View File

@ -0,0 +1,29 @@
@using Koogle.Application.DTOs
<MudDialog>
<DialogContent>
<MudText Class="mb-3">
Mitgliedschaftsantrag von <strong>@Pending.DisplayName</strong> für Club <strong>@Pending.ClubName</strong> ablehnen?
</MudText>
<MudTextField @bind-Value="_reason"
Label="Begründung (optional)"
Variant="Variant.Outlined"
Lines="3"
Placeholder="z.B. Kein Platz im Club verfügbar..." />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Submit">Ablehnen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public PendingMembershipDto Pending { get; set; } = null!;
private string _reason = "";
private void Cancel() => MudDialog.Cancel();
private void Submit() => MudDialog.Close(DialogResult.Ok(_reason));
}

View File

@ -15,73 +15,123 @@
<MudText Typo="Typo.h4" Class="mb-4">Benutzer verwalten</MudText>
@if (_isLoading)
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudTable Items="_users" Dense="true" Hover="true" Filter="FilterFunc" Loading="_isLoading">
<ToolBarContent>
<MudTextField @bind-Value="_searchString"
Placeholder="Suchen..."
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
IconSize="Size.Medium"
Class="mt-0" />
</ToolBarContent>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>E-Mail</MudTh>
<MudTh>Clubs</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.DisplayName</MudTd>
<MudTd DataLabel="E-Mail">@context.Identity.Email</MudTd>
<MudTd DataLabel="Clubs">
@if (context.ClubMemberships.Count == 0)
{
<MudText Typo="Typo.caption" Color="Color.Warning">Kein Club</MudText>
}
else
{
@foreach (var membership in context.ClubMemberships)
{
<MudChip T="string" Size="Size.Small" Color="@(membership.IsDefault ? Color.Primary : Color.Default)" Class="mr-1 mb-1">
@membership.ClubName
@if (membership.Roles.Any())
<MudTabs @bind-ActivePanelIndex="_activeTabIndex" Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Class="mb-4">
<MudTabPanel Text="Alle Benutzer" BadgeData="@(_users.Count)" BadgeColor="Color.Primary">
@if (_isLoading)
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudTable Items="_users" Dense="true" Hover="true" Filter="FilterFunc" Loading="_isLoading">
<ToolBarContent>
<MudTextField @bind-Value="_searchString"
Placeholder="Suchen..."
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
IconSize="Size.Medium"
Class="mt-0" />
</ToolBarContent>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>E-Mail</MudTh>
<MudTh>Clubs</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.DisplayName</MudTd>
<MudTd DataLabel="E-Mail">@context.Identity.Email</MudTd>
<MudTd DataLabel="Clubs">
@if (context.ClubMemberships.Count == 0)
{
<MudText Typo="Typo.caption" Color="Color.Warning">Kein Club</MudText>
}
else
{
@foreach (var membership in context.ClubMemberships)
{
<span class="ml-1">(@string.Join(", ", membership.Roles))</span>
<MudChip T="string" Size="Size.Small" Color="@(membership.IsDefault ? Color.Primary : Color.Default)" Class="mr-1 mb-1">
@membership.ClubName
@if (membership.Roles.Any())
{
<span class="ml-1">(@string.Join(", ", membership.Roles))</span>
}
</MudChip>
}
</MudChip>
}
}
</MudTd>
<MudTd DataLabel="Aktionen">
<MudTooltip Text="Club zuweisen">
<MudIconButton Icon="@Icons.Material.Filled.GroupAdd"
Size="Size.Small"
OnClick="@(() => OpenAssignClubDialog(context))"/>
</MudTooltip>
<MudTooltip Text="Rollen verwalten">
<MudIconButton Icon="@Icons.Material.Filled.Security"
Size="Size.Small"
OnClick="@(() => OpenManageRolesDialog(context))"/>
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
}
}
</MudTd>
<MudTd DataLabel="Aktionen">
<MudTooltip Text="Club zuweisen">
<MudIconButton Icon="@Icons.Material.Filled.GroupAdd"
Size="Size.Small"
OnClick="@(() => OpenAssignClubDialog(context))"/>
</MudTooltip>
<MudTooltip Text="Rollen verwalten">
<MudIconButton Icon="@Icons.Material.Filled.Security"
Size="Size.Small"
OnClick="@(() => OpenManageRolesDialog(context))"/>
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
}
</MudTabPanel>
<MudTabPanel Text="Ausstehende Anträge" BadgeData="@(_pendingMemberships.Count)" BadgeColor="@(_pendingMemberships.Any() ? Color.Warning : Color.Default)">
@if (_isLoadingPending)
{
<MudProgressCircular Indeterminate="true" />
}
else if (!_pendingMemberships.Any())
{
<MudAlert Severity="Severity.Info" Class="mt-4">Keine ausstehenden Mitgliedschaftsanträge.</MudAlert>
}
else
{
<MudTable Items="_pendingMemberships" Dense="true" Hover="true" Class="mt-2">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>E-Mail</MudTh>
<MudTh>Club</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="Club">@context.ClubName</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>
}
</MudTabPanel>
</MudTabs>
@code {
private List<UserDto> _users = new();
private List<ClubDto> _clubs = new();
private List<PendingMembershipDto> _pendingMemberships = new();
private bool _isLoading = true;
private bool _isLoadingPending = true;
private string _searchString = "";
private int _activeTabIndex = 0;
protected override async Task OnInitializedAsync()
{
@ -91,6 +141,7 @@ else
private async Task LoadData()
{
_isLoading = true;
_isLoadingPending = true;
try
{
var usersTask = UserService.GetAllUsersAsync();
@ -99,10 +150,33 @@ else
_users = (await usersTask).ToList();
_clubs = await clubsTask;
_isLoading = false;
await LoadPendingMemberships();
}
finally
{
_isLoading = false;
_isLoadingPending = false;
}
}
private async Task LoadPendingMemberships()
{
_isLoadingPending = true;
try
{
var allPending = new List<PendingMembershipDto>();
foreach (var club in _clubs)
{
var clubPending = await UserService.GetPendingMembershipsAsync(club.Id);
allPending.AddRange(clubPending);
}
_pendingMemberships = allPending.OrderByDescending(p => p.RequestedAt).ToList();
}
finally
{
_isLoadingPending = false;
}
}
@ -174,4 +248,58 @@ else
await LoadData();
}
}
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 LoadData();
}
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 LoadData();
}
else
{
Snackbar.Add("Fehler beim Ablehnen der Mitgliedschaft", Severity.Error);
}
}
}
}

View File

@ -0,0 +1,201 @@
@page "/club/join/{Token}"
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Microsoft.AspNetCore.Components.Authorization
@inject IClubService ClubService
@inject IUserService UserService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
<PageTitle>Club beitreten</PageTitle>
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-4">
<MudText Typo="Typo.h4" Class="mb-4">Einladung zum Club</MudText>
@if (_isLoading)
{
<MudProgressCircular Indeterminate="true" />
}
else if (_invalidToken)
{
<MudPaper Class="pa-6" Elevation="2">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.LinkOff" Size="Size.Large" Color="Color.Error" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">Ungueltige Einladung</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
@_errorMessage
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/account/join-club">
Club manuell suchen
</MudButton>
</div>
</MudPaper>
}
else if (_requestSent)
{
<MudPaper Class="pa-6" Elevation="2">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Large" Color="Color.Success" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">Beitrittsantrag gesendet!</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
Dein Antrag fuer <strong>@_invitation?.ClubName</strong> wurde gesendet.
Ein Admin wird ihn pruefen.
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/dashboard">
Zum Dashboard
</MudButton>
</div>
</MudPaper>
}
else if (_invitation is not null)
{
<MudPaper Class="pa-6" Elevation="2">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.GroupAdd" Size="Size.Large" Color="Color.Primary" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">@_invitation.ClubName</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
Du wurdest eingeladen, diesem Club beizutreten.
</MudText>
@if (!string.IsNullOrEmpty(_error))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitRequest"
Disabled="_isSubmitting"
StartIcon="@Icons.Material.Filled.PersonAdd">
@if (_isSubmitting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Beitreten
</MudButton>
</div>
</MudPaper>
}
</MudContainer>
@code {
[Parameter]
public string Token { get; set; } = "";
private ClubInvitationDto? _invitation;
private UserDto? _currentUser;
private bool _isLoading = true;
private bool _invalidToken;
private bool _requestSent;
private bool _isSubmitting;
private string? _error;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
await ValidateAndProcess();
}
private async Task ValidateAndProcess()
{
_isLoading = true;
try
{
// Check if user is authenticated
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
if (!isAuthenticated)
{
// Redirect to register with token
NavigationManager.NavigateTo($"/account/register?invite={Uri.EscapeDataString(Token)}");
return;
}
// Validate token
_invitation = await ClubService.GetInvitationByTokenAsync(Token);
if (_invitation is null)
{
_invalidToken = true;
_errorMessage = "Diese Einladung existiert nicht oder wurde geloescht.";
return;
}
if (!_invitation.IsValid)
{
_invalidToken = true;
_errorMessage = "Diese Einladung ist abgelaufen oder wurde bereits zu oft verwendet.";
return;
}
// Load current user
_currentUser = await UserService.GetCurrentUserAsync();
if (_currentUser is null)
{
_invalidToken = true;
_errorMessage = "Benutzerprofil konnte nicht geladen werden.";
return;
}
// Check if already member
var existingMembership = _currentUser.ClubMemberships
.FirstOrDefault(m => m.ClubId == _invitation.ClubId);
if (existingMembership is not null)
{
_invalidToken = true;
_errorMessage = "Du bist bereits Mitglied dieses Clubs oder hast einen offenen Antrag.";
return;
}
}
catch (Exception ex)
{
_invalidToken = true;
_errorMessage = $"Fehler beim Laden der Einladung: {ex.Message}";
}
finally
{
_isLoading = false;
}
}
private async Task SubmitRequest()
{
if (_invitation is null || _currentUser is null)
return;
_isSubmitting = true;
_error = null;
try
{
var success = await UserService.RequestClubMembershipByInviteAsync(
_currentUser.ProfileId,
Token);
if (success)
{
_requestSent = true;
Snackbar.Add("Beitrittsantrag erfolgreich gesendet", Severity.Success);
}
else
{
_error = "Der Antrag konnte nicht gesendet werden.";
}
}
catch (Exception ex)
{
_error = $"Fehler: {ex.Message}";
}
finally
{
_isSubmitting = false;
}
}
}

View File

@ -1,12 +1,16 @@
@page "/dashboard"
@attribute [Authorize(Policy = "ClubViewer")]
@attribute [Authorize]
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Koogle.Domain.Enums
@using Koogle.Infrastructure.Security
@using Koogle.Web.Components.Shared
@using Microsoft.AspNetCore.Authorization
@inject IDashboardService DashboardService
@inject IUserService UserService
@inject ICurrentClubContext CurrentClubContext
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@ -18,6 +22,21 @@
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_hasNoClub)
{
<MudPaper Class="pa-6" Elevation="2" MaxWidth="600px">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.GroupOff" Size="Size.Large" Color="Color.Secondary" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">Keinem Club zugeordnet</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
Du bist noch keinem Club zugeordnet. Um Koogle zu nutzen, tritt einem bestehenden Club bei.
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/account/join-club" StartIcon="@Icons.Material.Filled.GroupAdd">
Einem Club beitreten
</MudButton>
</div>
</MudPaper>
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
@ -102,6 +121,11 @@ else if (_dashboard is not null)
</MudCard>
</MudItem>
<!-- Pending Memberships Widget (Admin only) -->
<MudItem xs="12">
<PendingMembershipsWidget />
</MudItem>
<!-- Top Penalty Recipients -->
<MudItem xs="12" md="6">
<MudCard Elevation="2">
@ -143,6 +167,7 @@ else if (_dashboard is not null)
@code {
private DashboardDto? _dashboard;
private bool _isLoading = true;
private bool _hasNoClub;
private string? _error;
protected override async Task OnInitializedAsync()
@ -156,6 +181,20 @@ else if (_dashboard is not null)
{
_isLoading = true;
_error = null;
// Check if user has club context
var clubId = CurrentClubContext.ClubId;
if (clubId == Guid.Empty)
{
// Double-check via user service
var user = await UserService.GetCurrentUserAsync();
if (user == null || user.ClubMemberships.Count == 0)
{
_hasNoClub = true;
return;
}
}
_dashboard = await DashboardService.GetDashboardAsync();
}
catch (Exception ex)

View File

@ -124,17 +124,28 @@
[Parameter]
public Guid DayId { get; set; }
[Parameter]
public Guid? PreselectedPersonId { get; set; }
private MudForm? _form;
private bool _isValid;
private ExpenseDto? _selectedExpense;
private DayParticipantDto? _selectedParticipant;
private decimal _customPrice;
private bool _initialized;
private bool IsInverseMode => _selectedExpense?.IsInverse == true;
protected override void OnParametersSet()
{
base.OnParametersSet();
if (!_initialized && PreselectedPersonId.HasValue)
{
_selectedParticipant = Participants.FirstOrDefault(p => p.PersonId == PreselectedPersonId.Value);
_initialized = true;
}
if (_selectedExpense is not null)
{
_customPrice = _selectedExpense.Price;

View File

@ -99,16 +99,27 @@ else
}
else
{
<MudList T="DayParticipantDto" Dense="true">
<MudList T="DayParticipantDto" Dense="true" Class="participant-list">
@foreach (var participant in Day.Participants.OrderBy(p => p.PersonName))
{
<MudListItem T="DayParticipantDto">
var isSelected = _selectedParticipantId == participant.PersonId;
<MudListItem T="DayParticipantDto"
Class="@(isSelected ? "selected-participant" : "selectable-participant")"
OnClick="@(() => ToggleParticipantSelection(participant.PersonId))">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Style="width: 100%">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudAvatar Size="Size.Small" Color="@(participant.PersonStatus == PersonStatus.Member ? Color.Primary : Color.Secondary)">
@participant.PersonName[0]
</MudAvatar>
<MudText>@participant.PersonName</MudText>
<MudBadge Visible="@isSelected"
Color="Color.Success"
Icon="@Icons.Material.Filled.Check"
Overlap="true"
Bordered="true">
<MudAvatar Size="Size.Small"
Color="@(isSelected ? Color.Success : (participant.PersonStatus == PersonStatus.Member ? Color.Primary : Color.Secondary))"
Style="@(isSelected ? "border: 2px solid var(--mud-palette-success)" : "")">
@participant.PersonName[0]
</MudAvatar>
</MudBadge>
<MudText Style="@(isSelected ? "font-weight: 600" : "")">@participant.PersonName</MudText>
@if (participant.PersonStatus == PersonStatus.Guest)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary">Gast</MudChip>
@ -119,12 +130,21 @@ else
<MudIconButton Icon="@Icons.Material.Filled.PersonRemove"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => RemoveParticipant(participant))" />
OnClick="@(() => RemoveParticipant(participant))"
OnClickStopPropagation="true" />
}
</MudStack>
</MudListItem>
}
</MudList>
@if (_selectedParticipantId.HasValue)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mt-2">
<MudChip T="string" Color="Color.Success" Size="Size.Small" OnClose="ClearParticipantSelection">
@SelectedParticipant?.PersonName ausgewählt
</MudChip>
</MudStack>
}
}
</MudPaper>
</MudItem>
@ -204,14 +224,36 @@ else
</MudText>
@if (Day.Status != DayStatus.Closed)
{
<MudButton Variant="Variant.Text"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddExpenseDialog"
Size="Size.Small"
Disabled="@(Day.Participants.Count == 0)">
Strafe hinzufügen
</MudButton>
<MudStack Row="true" Spacing="1">
@if (OneClickExpenses.Count > 0 && _selectedParticipantId.HasValue)
{
<MudMenu Icon="@Icons.Material.Filled.FlashOn"
Color="Color.Warning"
Variant="Variant.Filled"
Size="Size.Small"
Title="Schnellzuweisung"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight">
@foreach (var expense in OneClickExpenses.OrderBy(e => e.Name))
{
<MudMenuItem OnClick="@(() => QuickAssignExpense(expense))">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Style="min-width: 200px">
<MudText>@expense.Name</MudText>
<MudText Color="Color.Secondary">@expense.Price.ToString("C")</MudText>
</MudStack>
</MudMenuItem>
}
</MudMenu>
}
<MudButton Variant="Variant.Text"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddExpenseDialog"
Size="Size.Small"
Disabled="@(Day.Participants.Count == 0)">
Strafe hinzufügen
</MudButton>
</MudStack>
}
</MudStack>
@ -331,6 +373,10 @@ else
private DayDto? Day => DayState.Value.SelectedDay;
private IReadOnlyList<PersonExpenseDto> Expenses => DayState.Value.SelectedDayExpenses;
private Guid? _selectedParticipantId;
private DayParticipantDto? SelectedParticipant => Day?.Participants.FirstOrDefault(p => p.PersonId == _selectedParticipantId);
private IReadOnlyList<ExpenseDto> OneClickExpenses => DayState.Value.AvailableExpenses.Where(e => e.IsOneClick && !e.IsVariable).ToList();
private decimal TotalExpenseAmount => Expenses.Sum(e => e.Price);
private decimal OpenExpenseAmount => Expenses.Where(e => e.PersonExpenseStatus == PersonExpenseStatus.Open).Sum(e => e.Price);
private decimal PaidExpenseAmount => Expenses.Where(e => e.PersonExpenseStatus == PersonExpenseStatus.Done).Sum(e => e.Price);
@ -359,6 +405,16 @@ else
Dispatcher.Dispatch(new ClearDayErrorAction());
}
private void ToggleParticipantSelection(Guid personId)
{
_selectedParticipantId = _selectedParticipantId == personId ? null : personId;
}
private void ClearParticipantSelection()
{
_selectedParticipantId = null;
}
private void NavigateBack()
{
NavigationManager.NavigateTo("/days");
@ -546,7 +602,8 @@ else
{
{ "AvailableExpenses", availableExpenses },
{ "Participants", Day.Participants },
{ "DayId", Day.Id }
{ "DayId", Day.Id },
{ "PreselectedPersonId", _selectedParticipantId }
};
var dialog = await DialogService.ShowAsync<AddPersonExpenseDialog>("Strafe hinzufügen", parameters);
@ -567,6 +624,37 @@ else
}
}
private void QuickAssignExpense(ExpenseDto expense)
{
if (Day is null || !_selectedParticipantId.HasValue) return;
if (expense.IsInverse)
{
var dto = new CreateInversePersonExpenseDto
{
ExcludedPersonId = _selectedParticipantId.Value,
ExpenseId = expense.Id,
DayId = Day.Id,
GameId = null
};
Dispatcher.Dispatch(new CreateInversePersonExpenseAction(dto));
Snackbar.Add($"{expense.Name} für alle außer {SelectedParticipant?.PersonName}...", Severity.Info);
}
else
{
var dto = new CreatePersonExpenseDto
{
PersonId = _selectedParticipantId.Value,
ExpenseId = expense.Id,
DayId = Day.Id,
GameId = null,
Price = null
};
Dispatcher.Dispatch(new CreatePersonExpenseAction(dto));
Snackbar.Add($"{expense.Name} für {SelectedParticipant?.PersonName}...", Severity.Info);
}
}
private void MarkAsPaid(PersonExpenseDto expense)
{
var dto = new UpdatePersonExpenseStatusDto

View File

@ -0,0 +1,21 @@
.participant-list ::deep .mud-list-item {
cursor: pointer;
transition: background-color 0.2s ease;
}
.selectable-participant {
border-left: 3px solid transparent;
}
.selectable-participant:hover {
background-color: var(--mud-palette-action-default-hover);
}
.selected-participant {
background-color: color-mix(in srgb, var(--mud-palette-success) 15%, transparent);
border-left: 3px solid var(--mud-palette-success);
}
.selected-participant:hover {
background-color: color-mix(in srgb, var(--mud-palette-success) 25%, transparent);
}

View File

@ -0,0 +1,90 @@
@using Fluxor
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Koogle.Web.Store.AuthState
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@inject IUserService UserService
@inject IState<AuthState> AuthState
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_pendingCount > 0)
{
<MudCard Elevation="2" Style="cursor: pointer" @onclick="NavigateToUsers">
<MudCardContent Class="d-flex flex-column align-center">
<MudBadge Content="@_pendingCount" Color="Color.Error" Overlap="true">
<MudIcon Icon="@Icons.Material.Filled.PersonAdd" Size="Size.Large" Color="Color.Warning" />
</MudBadge>
<MudText Typo="Typo.h6" Class="mt-2">Offene Beitrittsanträge</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
@(_pendingCount == 1 ? "1 Antrag wartet auf Freigabe" : $"{_pendingCount} Anträge warten auf Freigabe")
</MudText>
</MudCardContent>
</MudCard>
}
@code {
private int _pendingCount;
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadPendingCountAsync();
}
private async Task LoadPendingCountAsync()
{
try
{
_isLoading = true;
var state = AuthState.Value;
var clubId = state.CurrentClub?.ClubId ?? Guid.Empty;
if (clubId == Guid.Empty)
{
_pendingCount = 0;
return;
}
// Only load for club admins or super admins
if (!state.IsClubAdmin && !state.IsSuperAdmin)
{
_pendingCount = 0;
return;
}
var pending = await UserService.GetPendingMembershipsAsync(clubId);
_pendingCount = pending.Count;
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Anträge: {ex.Message}", Severity.Error);
_pendingCount = 0;
}
finally
{
_isLoading = false;
}
}
/// <summary>
/// Reloads pending membership count.
/// </summary>
public async Task RefreshAsync()
{
await LoadPendingCountAsync();
StateHasChanged();
}
private void NavigateToUsers()
{
NavigationManager.NavigateTo("/admin/users");
}
}

View File

@ -62,12 +62,20 @@ namespace Koogle.Web.Controllers
var result = await _userService.RegisterUserAsync(input);
if (result.Succeeded)
{
// If invite token was provided, redirect to join page
if (!string.IsNullOrWhiteSpace(input.InviteToken))
{
return LocalRedirect($"/account/login?registered=true&invite={Uri.EscapeDataString(input.InviteToken)}");
}
return LocalRedirect("/account/login?registered=true");
}
// Build error string from IdentityResult
var errors = string.Join(",", result.Errors.Select(e => e.Code));
return LocalRedirect($"/account/register?error={errors}");
var inviteParam = !string.IsNullOrWhiteSpace(input.InviteToken)
? $"&invite={Uri.EscapeDataString(input.InviteToken)}"
: "";
return LocalRedirect($"/account/register?error={errors}{inviteParam}");
}
/// <summary>