Compare commits

...

2 Commits

Author SHA1 Message Date
beo3000 005bfebe6d Complete phase E4: PersonExpense Management
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:15:01 +01:00
beo3000 343ad931d1 Add PersonExpense management to DayDetails (E4)
- Extend DayState with SelectedDayExpenses, AvailableExpenses
- Add Fluxor actions for load/create/update/delete expenses
- Add reducers + effects for PersonExpense operations
- Create AddPersonExpenseDialog with inverse expense support
- Update DayDetails with expense table, summary, status toggle

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:14:34 +01:00
7 changed files with 841 additions and 10 deletions

View File

@ -344,7 +344,7 @@ NavMenu.razor:
| ✓ | E1 | Days | DayState Fluxor | 4 State-Dateien |
| ✓ | E2 | Days | Days List Page | 1 Razor |
| ✓ | E3 | Days | Day Details Page | 1 Razor |
| | E4 | Days | PersonExpense Management | Components in DayDetails |
| | E4 | Days | PersonExpense Management | Components in DayDetails |
| ☐ | F1 | Dashboard | Dashboard Page | 1 Razor |
| ☐ | F2 | Dashboard | Evaluation Components | 2 Shared Components |
| ☐ | F3 | Navigation | NavMenu finalisieren | 1 Razor ändern |

View File

@ -0,0 +1,174 @@
@using Koogle.Application.DTOs
@using Koogle.Domain.Enums
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
@if (IsInverseMode)
{
<text>Inverse Strafe hinzufügen</text>
}
else
{
<text>Strafe hinzufügen</text>
}
</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="_isValid">
<!-- Expense Selection -->
<MudSelect T="ExpenseDto"
Label="Strafe"
@bind-Value="_selectedExpense"
Required="true"
RequiredError="Bitte wählen Sie eine Strafe"
ToStringFunc="@(e => e?.Name ?? string.Empty)"
Class="mb-4">
@foreach (var expense in AvailableExpenses.OrderBy(e => e.Name))
{
<MudSelectItem T="ExpenseDto" Value="expense">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Style="width: 100%">
<MudText>@expense.Name</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (expense.IsInverse)
{
<MudIcon Icon="@Icons.Material.Filled.SwapHoriz" Size="Size.Small" Color="Color.Warning" Title="Inverse" />
}
@if (expense.IsVariable)
{
<MudIcon Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Info" Title="Variable" />
}
<MudText Color="Color.Secondary">@expense.Price.ToString("C")</MudText>
</MudStack>
</MudStack>
</MudSelectItem>
}
</MudSelect>
@if (_selectedExpense?.IsInverse == true)
{
<MudAlert Severity="Severity.Info" Class="mb-4">
<MudText Typo="Typo.body2">
<strong>Inverse Strafe:</strong> Die Strafe wird allen Teilnehmern <em>außer</em> der ausgewählten Person zugewiesen.
</MudText>
</MudAlert>
}
<!-- Person Selection -->
<MudSelect T="DayParticipantDto"
Label="@(_selectedExpense?.IsInverse == true ? "Person ausschließen" : "Person")"
@bind-Value="_selectedParticipant"
Required="true"
RequiredError="Bitte wählen Sie eine Person"
ToStringFunc="@(p => p?.PersonName ?? string.Empty)"
Class="mb-4">
@foreach (var participant in Participants.OrderBy(p => p.PersonName))
{
<MudSelectItem T="DayParticipantDto" Value="participant">
<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>
@if (participant.PersonStatus == PersonStatus.Guest)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary">Gast</MudChip>
}
</MudStack>
</MudSelectItem>
}
</MudSelect>
<!-- Variable Price -->
@if (_selectedExpense?.IsVariable == true)
{
<MudNumericField T="decimal"
Label="Preis"
@bind-Value="_customPrice"
Min="0"
Format="C"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Euro"
Required="true"
RequiredError="Bitte geben Sie einen Preis ein" />
}
else if (_selectedExpense is not null)
{
<MudText Class="mb-4">
<strong>Preis:</strong> @_selectedExpense.Price.ToString("C")
</MudText>
}
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
Disabled="@(!_isValid || _selectedExpense is null || _selectedParticipant is null)"
OnClick="Submit">
Hinzufügen
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public IReadOnlyList<ExpenseDto> AvailableExpenses { get; set; } = [];
[Parameter]
public IReadOnlyList<DayParticipantDto> Participants { get; set; } = [];
[Parameter]
public Guid DayId { get; set; }
private MudForm? _form;
private bool _isValid;
private ExpenseDto? _selectedExpense;
private DayParticipantDto? _selectedParticipant;
private decimal _customPrice;
private bool IsInverseMode => _selectedExpense?.IsInverse == true;
protected override void OnParametersSet()
{
base.OnParametersSet();
if (_selectedExpense is not null)
{
_customPrice = _selectedExpense.Price;
}
}
private void Cancel() => MudDialog?.Cancel();
private void Submit()
{
if (_selectedExpense is null || _selectedParticipant is null) return;
if (_selectedExpense.IsInverse)
{
var dto = new CreateInversePersonExpenseDto
{
ExcludedPersonId = _selectedParticipant.PersonId,
ExpenseId = _selectedExpense.Id,
DayId = DayId,
GameId = null
};
MudDialog?.Close(DialogResult.Ok(dto));
}
else
{
var dto = new CreatePersonExpenseDto
{
PersonId = _selectedParticipant.PersonId,
ExpenseId = _selectedExpense.Id,
DayId = DayId,
GameId = null,
Price = _selectedExpense.IsVariable ? _customPrice : null
};
MudDialog?.Close(DialogResult.Ok(dto));
}
}
}

View File

@ -194,16 +194,131 @@ else
</MudPaper>
</MudItem>
<!-- PersonExpense placeholder for E4 -->
<!-- PersonExpense Section -->
<MudItem xs="12">
<MudPaper Class="pa-4">
<MudText Typo="Typo.h6" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.AttachMoney" Class="mr-2" />
Strafen / Kosten
</MudText>
<MudAlert Severity="Severity.Info">
Die Verwaltung von Strafen und Kosten wird in Phase E4 implementiert.
</MudAlert>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.AttachMoney" Class="mr-2" />
Strafen / Kosten (@Expenses.Count)
</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>
@if (DayState.Value.IsLoadingExpenses)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
}
@if (Day.Participants.Count == 0)
{
<MudAlert Severity="Severity.Info">
Fügen Sie zuerst Teilnehmer hinzu, um Strafen zuweisen zu können.
</MudAlert>
}
else if (Expenses.Count == 0)
{
<MudText Color="Color.Secondary">Keine Strafen zugewiesen</MudText>
}
else
{
<!-- Summary Section -->
<MudGrid Class="mb-4">
<MudItem xs="12" md="4">
<MudPaper Class="pa-3" Elevation="0" Outlined="true">
<MudStack AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Gesamt</MudText>
<MudText Typo="Typo.h5">@TotalExpenseAmount.ToString("C")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="6" md="4">
<MudPaper Class="pa-3" Elevation="0" Outlined="true">
<MudStack AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Offen</MudText>
<MudText Typo="Typo.h5" Color="Color.Warning">@OpenExpenseAmount.ToString("C")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="6" md="4">
<MudPaper Class="pa-3" Elevation="0" Outlined="true">
<MudStack AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Bezahlt</MudText>
<MudText Typo="Typo.h5" Color="Color.Success">@PaidExpenseAmount.ToString("C")</MudText>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Expense Table -->
<MudTable Items="@Expenses" Dense="true" Hover="true" Striped="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>Person</MudTh>
<MudTh>Strafe</MudTh>
<MudTh Style="text-align: right">Preis</MudTh>
<MudTh>Status</MudTh>
<MudTh Style="text-align: right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Person">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudAvatar Size="Size.Small" Color="Color.Primary">
@context.PersonName[0]
</MudAvatar>
<MudText>@context.PersonName</MudText>
</MudStack>
</MudTd>
<MudTd DataLabel="Strafe">@context.Name</MudTd>
<MudTd DataLabel="Preis" Style="text-align: right">@context.Price.ToString("C")</MudTd>
<MudTd DataLabel="Status">
<MudChip T="string"
Size="Size.Small"
Color="@(context.PersonExpenseStatus == PersonExpenseStatus.Open ? Color.Warning : Color.Success)"
Icon="@(context.PersonExpenseStatus == PersonExpenseStatus.Open ? Icons.Material.Filled.HourglassEmpty : Icons.Material.Filled.Check)">
@(context.PersonExpenseStatus == PersonExpenseStatus.Open ? "Offen" : "Bezahlt")
</MudChip>
</MudTd>
<MudTd DataLabel="Aktionen" Style="text-align: right">
<MudStack Row="true" Spacing="1" Justify="Justify.FlexEnd">
@if (context.PersonExpenseStatus == PersonExpenseStatus.Open)
{
<MudIconButton Icon="@Icons.Material.Filled.Check"
Size="Size.Small"
Color="Color.Success"
OnClick="@(() => MarkAsPaid(context))"
Title="Als bezahlt markieren" />
}
else
{
<MudIconButton Icon="@Icons.Material.Filled.Undo"
Size="Size.Small"
Color="Color.Warning"
OnClick="@(() => MarkAsOpen(context))"
Title="Als offen markieren" />
}
@if (Day.Status != DayStatus.Closed)
{
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmDeleteExpense(context))"
Title="Löschen" />
}
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
</MudItem>
</MudGrid>
@ -214,12 +329,19 @@ else
public Guid DayId { get; set; }
private DayDto? Day => DayState.Value.SelectedDay;
private IReadOnlyList<PersonExpenseDto> Expenses => DayState.Value.SelectedDayExpenses;
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);
protected override void OnInitialized()
{
base.OnInitialized();
Dispatcher.Dispatch(new LoadDayDetailsAction(DayId));
Dispatcher.Dispatch(new LoadAvailablePersonsAction());
Dispatcher.Dispatch(new LoadDayExpensesAction(DayId));
Dispatcher.Dispatch(new LoadAvailableExpensesAction());
}
protected override void OnParametersSet()
@ -228,6 +350,7 @@ else
if (Day?.Id != DayId)
{
Dispatcher.Dispatch(new LoadDayDetailsAction(DayId));
Dispatcher.Dispatch(new LoadDayExpensesAction(DayId));
}
}
@ -404,4 +527,86 @@ else
Snackbar.Add("Teilnehmer wird entfernt...", Severity.Info);
}
}
// PersonExpense Methods
private async Task OpenAddExpenseDialog()
{
if (Day is null) return;
var availableExpenses = DayState.Value.AvailableExpenses;
if (availableExpenses.Count == 0)
{
Snackbar.Add("Keine Strafen-Vorlagen definiert. Bitte erstellen Sie zuerst Vorlagen unter \"Strafen\".", Severity.Warning);
return;
}
var parameters = new DialogParameters
{
{ "AvailableExpenses", availableExpenses },
{ "Participants", Day.Participants },
{ "DayId", Day.Id }
};
var dialog = await DialogService.ShowAsync<AddPersonExpenseDialog>("Strafe hinzufügen", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is not null)
{
if (result.Data is CreateInversePersonExpenseDto inverseDto)
{
Dispatcher.Dispatch(new CreateInversePersonExpenseAction(inverseDto));
Snackbar.Add("Inverse Strafe wird erstellt...", Severity.Info);
}
else if (result.Data is CreatePersonExpenseDto createDto)
{
Dispatcher.Dispatch(new CreatePersonExpenseAction(createDto));
Snackbar.Add("Strafe wird hinzugefügt...", Severity.Info);
}
}
}
private void MarkAsPaid(PersonExpenseDto expense)
{
var dto = new UpdatePersonExpenseStatusDto
{
Id = expense.Id,
Status = PersonExpenseStatus.Done
};
Dispatcher.Dispatch(new UpdatePersonExpenseStatusAction(dto));
Snackbar.Add("Status wird aktualisiert...", Severity.Info);
}
private void MarkAsOpen(PersonExpenseDto expense)
{
var dto = new UpdatePersonExpenseStatusDto
{
Id = expense.Id,
Status = PersonExpenseStatus.Open
};
Dispatcher.Dispatch(new UpdatePersonExpenseStatusAction(dto));
Snackbar.Add("Status wird aktualisiert...", Severity.Info);
}
private async Task ConfirmDeleteExpense(PersonExpenseDto expense)
{
if (Day is null) return;
var parameters = new DialogParameters
{
{ "ContentText", $"Möchten Sie die Strafe \"{expense.Name}\" für \"{expense.PersonName}\" wirklich löschen?" },
{ "ButtonText", "Löschen" },
{ "Color", Color.Error }
};
var dialog = await DialogService.ShowAsync<Koogle.Web.Components.Shared.ConfirmDialog>("Strafe löschen", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Dispatcher.Dispatch(new DeletePersonExpenseAction(expense.Id, Day.Id));
Snackbar.Add("Strafe wird gelöscht...", Severity.Info);
}
}
}

View File

@ -169,3 +169,95 @@ public record ClearDayErrorAction;
/// Action to set filter for day list.
/// </summary>
public record SetDayFilterAction(DayFilterDto? Filter);
// PersonExpense Actions
/// <summary>
/// Action to load person expenses for a day.
/// </summary>
public record LoadDayExpensesAction(Guid DayId);
/// <summary>
/// Action dispatched when day expenses are loaded successfully.
/// </summary>
public record LoadDayExpensesSuccessAction(IReadOnlyList<PersonExpenseDto> Expenses);
/// <summary>
/// Action dispatched when loading day expenses fails.
/// </summary>
public record LoadDayExpensesFailureAction(string Error);
/// <summary>
/// Action to load available expense templates.
/// </summary>
public record LoadAvailableExpensesAction;
/// <summary>
/// Action dispatched when available expenses are loaded successfully.
/// </summary>
public record LoadAvailableExpensesSuccessAction(IReadOnlyList<ExpenseDto> Expenses);
/// <summary>
/// Action dispatched when loading available expenses fails.
/// </summary>
public record LoadAvailableExpensesFailureAction(string Error);
/// <summary>
/// Action to create a new person expense.
/// </summary>
public record CreatePersonExpenseAction(CreatePersonExpenseDto Dto);
/// <summary>
/// Action dispatched when person expense is created successfully.
/// </summary>
public record CreatePersonExpenseSuccessAction(PersonExpenseDto Expense);
/// <summary>
/// Action dispatched when creating person expense fails.
/// </summary>
public record CreatePersonExpenseFailureAction(string Error);
/// <summary>
/// Action to create an inverse person expense (charged to all except one).
/// </summary>
public record CreateInversePersonExpenseAction(CreateInversePersonExpenseDto Dto);
/// <summary>
/// Action dispatched when inverse expenses are created successfully.
/// </summary>
public record CreateInversePersonExpenseSuccessAction(IReadOnlyList<PersonExpenseDto> Expenses);
/// <summary>
/// Action dispatched when creating inverse expense fails.
/// </summary>
public record CreateInversePersonExpenseFailureAction(string Error);
/// <summary>
/// Action to delete a person expense.
/// </summary>
public record DeletePersonExpenseAction(Guid Id, Guid DayId);
/// <summary>
/// Action dispatched when person expense is deleted successfully.
/// </summary>
public record DeletePersonExpenseSuccessAction(Guid Id);
/// <summary>
/// Action dispatched when deleting person expense fails.
/// </summary>
public record DeletePersonExpenseFailureAction(string Error);
/// <summary>
/// Action to update person expense status (Open/Done).
/// </summary>
public record UpdatePersonExpenseStatusAction(UpdatePersonExpenseStatusDto Dto);
/// <summary>
/// Action dispatched when person expense status is updated successfully.
/// </summary>
public record UpdatePersonExpenseStatusSuccessAction(PersonExpenseDto Expense);
/// <summary>
/// Action dispatched when updating person expense status fails.
/// </summary>
public record UpdatePersonExpenseStatusFailureAction(string Error);

View File

@ -11,15 +11,24 @@ public class DayEffects
{
private readonly IDayService _dayService;
private readonly IPersonService _personService;
private readonly IExpenseService _expenseService;
private readonly IPersonExpenseService _personExpenseService;
private readonly ILogger<DayEffects> _logger;
/// <summary>
/// Initializes a new instance of the DayEffects class.
/// </summary>
public DayEffects(IDayService dayService, IPersonService personService, ILogger<DayEffects> logger)
public DayEffects(
IDayService dayService,
IPersonService personService,
IExpenseService expenseService,
IPersonExpenseService personExpenseService,
ILogger<DayEffects> logger)
{
_dayService = dayService;
_personService = personService;
_expenseService = expenseService;
_personExpenseService = personExpenseService;
_logger = logger;
}
@ -218,4 +227,135 @@ public class DayEffects
dispatcher.Dispatch(new LoadAvailablePersonsFailureAction(ex.Message));
}
}
// PersonExpense Effects
/// <summary>
/// Handles LoadDayExpensesAction - loads expenses for a day.
/// </summary>
[EffectMethod]
public async Task HandleLoadDayExpenses(LoadDayExpensesAction action, IDispatcher dispatcher)
{
try
{
_logger.LogDebug("Loading expenses for day {DayId}", action.DayId);
var expenses = await _personExpenseService.GetByDayIdAsync(action.DayId);
dispatcher.Dispatch(new LoadDayExpensesSuccessAction(expenses));
_logger.LogInformation("Loaded {Count} expenses for day {DayId}", expenses.Count, action.DayId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load expenses for day {DayId}", action.DayId);
dispatcher.Dispatch(new LoadDayExpensesFailureAction(ex.Message));
}
}
/// <summary>
/// Handles LoadAvailableExpensesAction - loads expense templates.
/// </summary>
[EffectMethod]
public async Task HandleLoadAvailableExpenses(LoadAvailableExpensesAction action, IDispatcher dispatcher)
{
try
{
_logger.LogDebug("Loading available expense templates");
var expenses = await _expenseService.GetAllAsync();
dispatcher.Dispatch(new LoadAvailableExpensesSuccessAction(expenses));
_logger.LogInformation("Loaded {Count} available expense templates", expenses.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load available expense templates");
dispatcher.Dispatch(new LoadAvailableExpensesFailureAction(ex.Message));
}
}
/// <summary>
/// Handles CreatePersonExpenseAction - creates a person expense.
/// </summary>
[EffectMethod]
public async Task HandleCreatePersonExpense(CreatePersonExpenseAction action, IDispatcher dispatcher)
{
try
{
_logger.LogDebug("Creating person expense for person {PersonId}, expense {ExpenseId}", action.Dto.PersonId, action.Dto.ExpenseId);
var expense = await _personExpenseService.CreateAsync(action.Dto);
dispatcher.Dispatch(new CreatePersonExpenseSuccessAction(expense));
_logger.LogInformation("Created person expense {Id}", expense.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create person expense");
dispatcher.Dispatch(new CreatePersonExpenseFailureAction(ex.Message));
}
}
/// <summary>
/// Handles CreateInversePersonExpenseAction - creates inverse expenses.
/// </summary>
[EffectMethod]
public async Task HandleCreateInversePersonExpense(CreateInversePersonExpenseAction action, IDispatcher dispatcher)
{
try
{
_logger.LogDebug("Creating inverse expense (excluded: {ExcludedPersonId}) for expense {ExpenseId}", action.Dto.ExcludedPersonId, action.Dto.ExpenseId);
var expenses = await _personExpenseService.CreateInverseAsync(action.Dto);
dispatcher.Dispatch(new CreateInversePersonExpenseSuccessAction(expenses));
_logger.LogInformation("Created {Count} inverse expenses", expenses.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create inverse expense");
dispatcher.Dispatch(new CreateInversePersonExpenseFailureAction(ex.Message));
}
}
/// <summary>
/// Handles DeletePersonExpenseAction - deletes a person expense.
/// </summary>
[EffectMethod]
public async Task HandleDeletePersonExpense(DeletePersonExpenseAction action, IDispatcher dispatcher)
{
try
{
_logger.LogDebug("Deleting person expense {Id}", action.Id);
var success = await _personExpenseService.DeleteAsync(action.Id);
if (success)
{
dispatcher.Dispatch(new DeletePersonExpenseSuccessAction(action.Id));
_logger.LogInformation("Deleted person expense {Id}", action.Id);
}
else
{
dispatcher.Dispatch(new DeletePersonExpenseFailureAction("Strafe konnte nicht gelöscht werden."));
_logger.LogWarning("Failed to delete person expense {Id}", action.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete person expense {Id}", action.Id);
dispatcher.Dispatch(new DeletePersonExpenseFailureAction(ex.Message));
}
}
/// <summary>
/// Handles UpdatePersonExpenseStatusAction - updates expense status.
/// </summary>
[EffectMethod]
public async Task HandleUpdatePersonExpenseStatus(UpdatePersonExpenseStatusAction action, IDispatcher dispatcher)
{
try
{
_logger.LogDebug("Updating person expense {Id} status to {Status}", action.Dto.Id, action.Dto.Status);
var expense = await _personExpenseService.UpdateStatusAsync(action.Dto);
dispatcher.Dispatch(new UpdatePersonExpenseStatusSuccessAction(expense));
_logger.LogInformation("Updated person expense {Id} status to {Status}", expense.Id, expense.PersonExpenseStatus);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update person expense {Id} status", action.Dto.Id);
dispatcher.Dispatch(new UpdatePersonExpenseStatusFailureAction(ex.Message));
}
}
}

View File

@ -420,4 +420,206 @@ public static class DayReducers
{
Filter = action.Filter
};
// PersonExpense Reducers
/// <summary>
/// Handles LoadDayExpensesAction - sets loading state.
/// </summary>
[ReducerMethod(typeof(LoadDayExpensesAction))]
public static DayState OnLoadDayExpenses(DayState state)
=> state with
{
IsLoadingExpenses = true,
Error = null
};
/// <summary>
/// Handles LoadDayExpensesSuccessAction - sets expenses.
/// </summary>
[ReducerMethod]
public static DayState OnLoadDayExpensesSuccess(DayState state, LoadDayExpensesSuccessAction action)
=> state with
{
SelectedDayExpenses = action.Expenses,
IsLoadingExpenses = false
};
/// <summary>
/// Handles LoadDayExpensesFailureAction - sets error state.
/// </summary>
[ReducerMethod]
public static DayState OnLoadDayExpensesFailure(DayState state, LoadDayExpensesFailureAction action)
=> state with
{
IsLoadingExpenses = false,
Error = action.Error
};
/// <summary>
/// Handles LoadAvailableExpensesAction - sets loading state.
/// </summary>
[ReducerMethod(typeof(LoadAvailableExpensesAction))]
public static DayState OnLoadAvailableExpenses(DayState state)
=> state with
{
IsLoading = true,
Error = null
};
/// <summary>
/// Handles LoadAvailableExpensesSuccessAction - sets available expenses.
/// </summary>
[ReducerMethod]
public static DayState OnLoadAvailableExpensesSuccess(DayState state, LoadAvailableExpensesSuccessAction action)
=> state with
{
AvailableExpenses = action.Expenses,
IsLoading = false
};
/// <summary>
/// Handles LoadAvailableExpensesFailureAction - sets error state.
/// </summary>
[ReducerMethod]
public static DayState OnLoadAvailableExpensesFailure(DayState state, LoadAvailableExpensesFailureAction action)
=> state with
{
IsLoading = false,
Error = action.Error
};
/// <summary>
/// Handles CreatePersonExpenseAction - sets loading state.
/// </summary>
[ReducerMethod(typeof(CreatePersonExpenseAction))]
public static DayState OnCreatePersonExpense(DayState state)
=> state with
{
IsLoadingExpenses = true,
Error = null
};
/// <summary>
/// Handles CreatePersonExpenseSuccessAction - adds expense to list.
/// </summary>
[ReducerMethod]
public static DayState OnCreatePersonExpenseSuccess(DayState state, CreatePersonExpenseSuccessAction action)
=> state with
{
SelectedDayExpenses = [.. state.SelectedDayExpenses, action.Expense],
IsLoadingExpenses = false
};
/// <summary>
/// Handles CreatePersonExpenseFailureAction - sets error state.
/// </summary>
[ReducerMethod]
public static DayState OnCreatePersonExpenseFailure(DayState state, CreatePersonExpenseFailureAction action)
=> state with
{
IsLoadingExpenses = false,
Error = action.Error
};
/// <summary>
/// Handles CreateInversePersonExpenseAction - sets loading state.
/// </summary>
[ReducerMethod(typeof(CreateInversePersonExpenseAction))]
public static DayState OnCreateInversePersonExpense(DayState state)
=> state with
{
IsLoadingExpenses = true,
Error = null
};
/// <summary>
/// Handles CreateInversePersonExpenseSuccessAction - adds expenses to list.
/// </summary>
[ReducerMethod]
public static DayState OnCreateInversePersonExpenseSuccess(DayState state, CreateInversePersonExpenseSuccessAction action)
=> state with
{
SelectedDayExpenses = [.. state.SelectedDayExpenses, .. action.Expenses],
IsLoadingExpenses = false
};
/// <summary>
/// Handles CreateInversePersonExpenseFailureAction - sets error state.
/// </summary>
[ReducerMethod]
public static DayState OnCreateInversePersonExpenseFailure(DayState state, CreateInversePersonExpenseFailureAction action)
=> state with
{
IsLoadingExpenses = false,
Error = action.Error
};
/// <summary>
/// Handles DeletePersonExpenseAction - sets loading state.
/// </summary>
[ReducerMethod(typeof(DeletePersonExpenseAction))]
public static DayState OnDeletePersonExpense(DayState state)
=> state with
{
IsLoadingExpenses = true,
Error = null
};
/// <summary>
/// Handles DeletePersonExpenseSuccessAction - removes expense from list.
/// </summary>
[ReducerMethod]
public static DayState OnDeletePersonExpenseSuccess(DayState state, DeletePersonExpenseSuccessAction action)
=> state with
{
SelectedDayExpenses = state.SelectedDayExpenses.Where(e => e.Id != action.Id).ToList(),
IsLoadingExpenses = false
};
/// <summary>
/// Handles DeletePersonExpenseFailureAction - sets error state.
/// </summary>
[ReducerMethod]
public static DayState OnDeletePersonExpenseFailure(DayState state, DeletePersonExpenseFailureAction action)
=> state with
{
IsLoadingExpenses = false,
Error = action.Error
};
/// <summary>
/// Handles UpdatePersonExpenseStatusAction - sets loading state.
/// </summary>
[ReducerMethod(typeof(UpdatePersonExpenseStatusAction))]
public static DayState OnUpdatePersonExpenseStatus(DayState state)
=> state with
{
IsLoadingExpenses = true,
Error = null
};
/// <summary>
/// Handles UpdatePersonExpenseStatusSuccessAction - updates expense in list.
/// </summary>
[ReducerMethod]
public static DayState OnUpdatePersonExpenseStatusSuccess(DayState state, UpdatePersonExpenseStatusSuccessAction action)
=> state with
{
SelectedDayExpenses = state.SelectedDayExpenses
.Select(e => e.Id == action.Expense.Id ? action.Expense : e)
.ToList(),
IsLoadingExpenses = false
};
/// <summary>
/// Handles UpdatePersonExpenseStatusFailureAction - sets error state.
/// </summary>
[ReducerMethod]
public static DayState OnUpdatePersonExpenseStatusFailure(DayState state, UpdatePersonExpenseStatusFailureAction action)
=> state with
{
IsLoadingExpenses = false,
Error = action.Error
};
}

View File

@ -19,6 +19,16 @@ public record DayState
/// </summary>
public DayDto? SelectedDay { get; init; }
/// <summary>
/// PersonExpenses for the currently selected day.
/// </summary>
public IReadOnlyList<PersonExpenseDto> SelectedDayExpenses { get; init; } = [];
/// <summary>
/// Available expense templates for the current club.
/// </summary>
public IReadOnlyList<ExpenseDto> AvailableExpenses { get; init; } = [];
/// <summary>
/// Available persons for participant selection.
/// </summary>
@ -34,6 +44,11 @@ public record DayState
/// </summary>
public bool IsLoading { get; init; }
/// <summary>
/// Indicates whether expenses are loading.
/// </summary>
public bool IsLoadingExpenses { get; init; }
/// <summary>
/// Error message if operation failed.
/// </summary>
@ -51,9 +66,12 @@ public record DayState
{
Days = [],
SelectedDay = null,
SelectedDayExpenses = [],
AvailableExpenses = [],
AvailablePersons = [],
Filter = null,
IsLoading = false,
IsLoadingExpenses = false,
Error = null
};
}