Add Expense Pages (D4)

This commit is contained in:
beo3000 2025-12-24 17:23:31 +01:00
parent 69f0f404a6
commit 51600596c2
3 changed files with 361 additions and 55 deletions

View File

@ -7,34 +7,9 @@ using System.Threading.Tasks;
namespace Koogle.Infrastructure.Security;
public sealed class ClubRoleRequirement : IAuthorizationRequirement
public static class ClubRoleHelper
{
public ClubRoleRequirement(string requiredRole) => RequiredRole = requiredRole;
public string RequiredRole { get; }
}
/// <summary>
/// Handles ClubRoleRequirement when no resource is passed (e.g., [Authorize(Policy = "ClubViewer")]).
/// Uses current_club_id claim to determine the club context.
/// </summary>
public sealed class ClubRoleHandler : AuthorizationHandler<ClubRoleRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ClubRoleRequirement requirement)
{
// Get current club from claims
var currentClubClaim = context.User.FindFirst("current_club_id");
if (currentClubClaim == null || !Guid.TryParse(currentClubClaim.Value, out var clubId))
{
return Task.CompletedTask; // No club context, fail
}
return CheckClubRole(context, requirement, clubId);
}
private static Task CheckClubRole(
public static Task CheckClubRole(
AuthorizationHandlerContext context,
ClubRoleRequirement requirement,
Guid clubId)
@ -72,6 +47,35 @@ public sealed class ClubRoleHandler : AuthorizationHandler<ClubRoleRequirement>
}
}
public sealed class ClubRoleRequirement : IAuthorizationRequirement
{
public ClubRoleRequirement(string requiredRole) => RequiredRole = requiredRole;
public string RequiredRole { get; }
}
/// <summary>
/// Handles ClubRoleRequirement when no resource is passed (e.g., [Authorize(Policy = "ClubViewer")]).
/// Uses current_club_id claim to determine the club context.
/// </summary>
public sealed class ClubRoleHandler : AuthorizationHandler<ClubRoleRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ClubRoleRequirement requirement)
{
// Get current club from claims
var currentClubClaim = context.User.FindFirst("current_club_id");
if (currentClubClaim == null || !Guid.TryParse(currentClubClaim.Value, out var clubId))
{
return Task.CompletedTask; // No club context, fail
}
return ClubRoleHelper.CheckClubRole(context, requirement, clubId);
}
}
/// <summary>
/// Handles ClubRoleRequirement when a specific clubId resource is passed.
/// </summary>
@ -82,33 +86,6 @@ public sealed class ClubRoleResourceHandler : AuthorizationHandler<ClubRoleRequi
ClubRoleRequirement requirement,
Guid clubId)
{
static int Rank(string role) => role switch
{
"Admin" => 3,
"Editor" => 2,
"Viewer" => 1,
_ => 0
};
var requiredRank = Rank(requirement.RequiredRole);
var claims = context.User.FindAll("club_role");
foreach (var c in claims)
{
var parts = c.Value.Split(':', 2);
if (parts.Length != 2) continue;
if (Guid.TryParse(parts[0], out var claimClubId) && claimClubId == clubId)
{
var userRole = parts[1];
if (Rank(userRole) >= requiredRank)
{
context.Succeed(requirement);
break;
}
}
}
return Task.CompletedTask;
return ClubRoleHelper.CheckClubRole(context, requirement, clubId);
}
}

View File

@ -0,0 +1,136 @@
@using Koogle.Application.DTOs
@using Koogle.Domain.Enums
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
@(IsEditMode ? "Kosten-Vorlage bearbeiten" : "Neue Kosten-Vorlage")
</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="_isValid">
<MudTextField @bind-Value="_name"
Label="Name"
Required="true"
RequiredError="Name ist erforderlich"
Immediate="true"
Class="mb-4" />
<MudTextField @bind-Value="_description"
Label="Beschreibung"
Lines="2"
Class="mb-4" />
<MudNumericField @bind-Value="_price"
Label="Preis"
Format="N2"
Adornment="Adornment.Start"
AdornmentText="€"
Min="0m"
Class="mb-4" />
<MudSelect T="ExpenseType" @bind-Value="_expenseType"
Label="Typ"
AnchorOrigin="Origin.BottomCenter"
Class="mb-4">
<MudSelectItem Value="ExpenseType.Monetary">Geldstrafe</MudSelectItem>
<MudSelectItem Value="ExpenseType.Material">Sachleistung</MudSelectItem>
</MudSelect>
<MudDivider Class="my-4" />
<MudText Typo="Typo.subtitle2" Class="mb-2">Optionen</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudCheckBox @bind-Value="_isOneClick" Label="One-Click" Color="Color.Primary" Class="mb-2"/>
<MudText Typo="Typo.caption" Color="Color.Secondary">Als Schnellauswahl-Button anzeigen</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudCheckBox @bind-Value="_isVariable" Label="Variabler Preis" Color="Color.Primary" Class="mb-2"/>
<MudText Typo="Typo.caption" Color="Color.Secondary">Preis kann bei Zuweisung angepasst werden</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudCheckBox @bind-Value="_isInverse" Label="Inverse Kosten" Color="Color.Primary"/>
<MudText Typo="Typo.caption" Color="Color.Secondary">Alle außer der Verursacher zahlen</MudText>
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="!_isValid" OnClick="Submit">
@(IsEditMode ? "Speichern" : "Erstellen")
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public ExpenseDto? Expense { get; set; }
private MudForm? _form;
private bool _isValid;
private string _name = "";
private string _description = "";
private decimal _price;
private ExpenseType _expenseType = ExpenseType.Monetary;
private bool _isOneClick;
private bool _isInverse;
private bool _isVariable;
private bool IsEditMode => Expense is not null;
protected override void OnInitialized()
{
if (Expense is not null)
{
_name = Expense.Name;
_description = Expense.Description;
_price = Expense.Price;
_expenseType = Expense.ExpenseType;
_isOneClick = Expense.IsOneClick;
_isInverse = Expense.IsInverse;
_isVariable = Expense.IsVariable;
}
}
private void Cancel() => MudDialog?.Cancel();
private void Submit()
{
if (!_isValid) return;
if (IsEditMode)
{
var dto = new UpdateExpenseDto
{
Id = Expense!.Id,
Name = _name,
Description = _description,
Price = _price,
ExpenseType = _expenseType,
IsOneClick = _isOneClick,
IsInverse = _isInverse,
IsVariable = _isVariable
};
MudDialog?.Close(DialogResult.Ok(dto));
}
else
{
var dto = new CreateExpenseDto
{
Name = _name,
Description = _description,
Price = _price,
ExpenseType = _expenseType,
IsOneClick = _isOneClick,
IsInverse = _isInverse,
IsVariable = _isVariable
};
MudDialog?.Close(DialogResult.Ok(dto));
}
}
}

View File

@ -0,0 +1,193 @@
@page "/expenses"
@attribute [Authorize(Policy = "ClubViewer")]
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@using Fluxor
@using Koogle.Application.DTOs
@using Koogle.Domain.Enums
@using Koogle.Web.Store.ExpenseState
@using Microsoft.AspNetCore.Authorization
@inject IState<ExpenseState> ExpenseState
@inject IDispatcher Dispatcher
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Kosten-Vorlagen</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Kosten-Vorlagen</MudText>
@if (ExpenseState.Value.Error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="ClearError">
@ExpenseState.Value.Error
</MudAlert>
}
<MudTable Items="FilteredExpenses" Dense="true" Hover="true" Loading="ExpenseState.Value.IsLoading"
Filter="FilterFunc">
<ToolBarContent>
<MudTextField @bind-Value="_searchString"
Placeholder="Suchen..."
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
IconSize="Size.Medium"
Class="mt-0" />
<MudSpacer />
<MudSelect T="ExpenseType?" @bind-Value="_typeFilter" Label="Typ" Clearable="true" Class="mr-4" Style="width: 150px;">
<MudSelectItem Value="@((ExpenseType?)ExpenseType.Monetary)">Geldstrafe</MudSelectItem>
<MudSelectItem Value="@((ExpenseType?)ExpenseType.Material)">Sachleistung</MudSelectItem>
</MudSelect>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog">
Neue Vorlage
</MudButton>
</ToolBarContent>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Preis</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Optionen</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Preis">@context.Price.ToString("C")</MudTd>
<MudTd DataLabel="Typ">
<MudChip T="string" Size="Size.Small" Color="GetTypeColor(context.ExpenseType)">
@GetTypeLabel(context.ExpenseType)
</MudChip>
</MudTd>
<MudTd DataLabel="Optionen">
@if (context.IsOneClick)
{
<MudTooltip Text="One-Click: Schnellauswahl">
<MudIcon Icon="@Icons.Material.Filled.TouchApp" Size="Size.Small" Class="mr-1" />
</MudTooltip>
}
@if (context.IsInverse)
{
<MudTooltip Text="Invers: Alle außer einer Person">
<MudIcon Icon="@Icons.Material.Filled.GroupRemove" Size="Size.Small" Class="mr-1" />
</MudTooltip>
}
@if (context.IsVariable)
{
<MudTooltip Text="Variabel: Preis anpassbar">
<MudIcon Icon="@Icons.Material.Filled.Edit" Size="Size.Small" />
</MudTooltip>
}
</MudTd>
<MudTd DataLabel="Aktionen">
<MudTooltip Text="Bearbeiten">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
OnClick="@(() => OpenEditDialog(context))"/>
</MudTooltip>
<MudTooltip Text="Löschen">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmDelete(context))"/>
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
<NoRecordsContent>
<MudText>Keine Kosten-Vorlagen gefunden</MudText>
</NoRecordsContent>
</MudTable>
@code {
private string _searchString = "";
private ExpenseType? _typeFilter;
private IEnumerable<ExpenseDto> FilteredExpenses => _typeFilter.HasValue
? ExpenseState.Value.Expenses.Where(e => e.ExpenseType == _typeFilter.Value)
: ExpenseState.Value.Expenses;
protected override void OnInitialized()
{
base.OnInitialized();
Dispatcher.Dispatch(new LoadExpensesAction());
}
private bool FilterFunc(ExpenseDto expense)
{
if (string.IsNullOrWhiteSpace(_searchString))
return true;
return expense.Name.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ||
expense.Description.Contains(_searchString, StringComparison.OrdinalIgnoreCase);
}
private void ClearError()
{
Dispatcher.Dispatch(new ClearExpenseErrorAction());
}
private static string GetTypeLabel(ExpenseType type) => type switch
{
ExpenseType.Monetary => "Geldstrafe",
ExpenseType.Material => "Sachleistung",
_ => type.ToString()
};
private static Color GetTypeColor(ExpenseType type) => type switch
{
ExpenseType.Monetary => Color.Warning,
ExpenseType.Material => Color.Info,
_ => Color.Default
};
private async Task OpenCreateDialog()
{
var dialog = await DialogService.ShowAsync<ExpenseEditDialog>("Neue Kosten-Vorlage");
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is CreateExpenseDto dto)
{
Dispatcher.Dispatch(new CreateExpenseAction(dto));
Snackbar.Add("Vorlage wird erstellt...", Severity.Info);
}
}
private async Task OpenEditDialog(ExpenseDto expense)
{
var parameters = new DialogParameters
{
{ "Expense", expense }
};
var dialog = await DialogService.ShowAsync<ExpenseEditDialog>("Kosten-Vorlage bearbeiten", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is UpdateExpenseDto dto)
{
Dispatcher.Dispatch(new UpdateExpenseAction(dto));
Snackbar.Add("Vorlage wird aktualisiert...", Severity.Info);
}
}
private async Task ConfirmDelete(ExpenseDto expense)
{
var parameters = new DialogParameters
{
{ "ContentText", $"Möchten Sie \"{expense.Name}\" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." },
{ "ButtonText", "Löschen" },
{ "Color", Color.Error }
};
var dialog = await DialogService.ShowAsync<Koogle.Web.Components.Shared.ConfirmDialog>("Kosten-Vorlage löschen", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Dispatcher.Dispatch(new DeleteExpenseAction(expense.Id));
Snackbar.Add("Vorlage wird gelöscht...", Severity.Info);
}
}
}