● K15 fertig. Erstellte Dateien:

- src/Koogle.Web/Components/Pages/CashBook/Reports.razor - UI mit Monat/Jahr-Auswahl, Saldo-Karten, Donut-Charts für Kategorien, expandierbare Buchungsliste
  - src/Koogle.Web/Components/Pages/CashBook/Reports.razor.cs - Code-behind

  Route: /cashbook/reports | Policy: ClubViewer

  Export-Buttons (Excel/PDF) zeigen Info-Message - werden in K16/K17 implementiert.
This commit is contained in:
beo3000 2026-01-03 21:55:02 +01:00
parent e1f73691c4
commit dfe74adf01
3 changed files with 294 additions and 1 deletions

View File

@ -1713,7 +1713,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 }
| ✓ | K12 | Web | Fluxor CashBookState | 4 |
| ✓ | K13 | Web | CashBook UI | 3 |
| ✓ | K14 | Web | Categories UI | 2 |
| | K15 | Web | Reports UI | 2 |
| | K15 | Web | Reports UI | 2 |
| ☐ | K16 | Application | Excel Export (ClosedXML) | 3 |
| ☐ | K17 | Application | PDF Export (QuestPDF) | 1 |
| ☐ | K18 | Web | Export Controller | 1 |

View File

@ -0,0 +1,199 @@
@page "/cashbook/reports"
@attribute [Authorize(Policy = "ClubViewer")]
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@using Fluxor
@using Koogle.Application.DTOs
@using Koogle.Domain.Enums
@using Koogle.Web.Store.CashBookState
@using Microsoft.AspNetCore.Authorization
@inject IState<CashBookState> CashBookState
@inject IDispatcher Dispatcher
@inject ISnackbar Snackbar
<PageTitle>Kassenbericht</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Kassenbericht</MudText>
@if (CashBookState.Value.Error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="ClearError">
@CashBookState.Value.Error
</MudAlert>
}
@* Period Selection *@
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row="true" Spacing="4" AlignItems="AlignItems.Center">
<MudSelect T="int" @bind-Value="_selectedMonth" Label="Monat" Style="width: 150px;">
@for (int m = 1; m <= 12; m++)
{
<MudSelectItem Value="@m">@GetMonthName(m)</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" @bind-Value="_selectedYear" Label="Jahr" Style="width: 120px;">
@for (int y = DateTime.Today.Year; y >= DateTime.Today.Year - 5; y--)
{
<MudSelectItem Value="@y">@y</MudSelectItem>
}
</MudSelect>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadReport" Disabled="CashBookState.Value.IsLoading">
Laden
</MudButton>
<MudSpacer />
<MudButtonGroup Variant="Variant.Outlined" Color="Color.Secondary">
<MudButton StartIcon="@Icons.Material.Filled.TableChart" OnClick="ExportExcel" Disabled="Report is null">
Excel
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.PictureAsPdf" OnClick="ExportPdf" Disabled="Report is null">
PDF
</MudButton>
</MudButtonGroup>
</MudStack>
</MudPaper>
@if (CashBookState.Value.IsLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
@if (Report is not null)
{
@* Summary Cards *@
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudStack>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Anfangssaldo</MudText>
<MudText Typo="Typo.h5" Color="@(Report.OpeningBalance >= 0 ? Color.Success : Color.Error)">
@Report.OpeningBalance.ToString("C")
</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudStack>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Einnahmen</MudText>
<MudText Typo="Typo.h5" Color="Color.Success">
+@Report.TotalIncome.ToString("C")
</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudStack>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Ausgaben</MudText>
<MudText Typo="Typo.h5" Color="Color.Error">
-@Report.TotalExpense.ToString("C")
</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudStack>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Endsaldo</MudText>
<MudText Typo="Typo.h5" Color="@(Report.ClosingBalance >= 0 ? Color.Success : Color.Error)">
@Report.ClosingBalance.ToString("C")
</MudText>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
@* Charts *@
<MudGrid Class="mb-4">
@if (Report.IncomeByCategory.Count > 0)
{
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Einnahmen nach Kategorie</MudText>
<MudChart ChartType="ChartType.Donut"
InputData="@IncomeChartData"
InputLabels="@IncomeChartLabels"
Width="100%"
Height="250px" />
<MudStack Class="mt-2">
@foreach (var cat in Report.IncomeByCategory.OrderByDescending(c => c.Total))
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">@cat.CategoryName (@cat.Count)</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">@cat.Total.ToString("C")</MudText>
</MudStack>
}
</MudStack>
</MudPaper>
</MudItem>
}
@if (Report.ExpenseByCategory.Count > 0)
{
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Ausgaben nach Kategorie</MudText>
<MudChart ChartType="ChartType.Donut"
InputData="@ExpenseChartData"
InputLabels="@ExpenseChartLabels"
Width="100%"
Height="250px" />
<MudStack Class="mt-2">
@foreach (var cat in Report.ExpenseByCategory.OrderByDescending(c => c.Total))
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">@cat.CategoryName (@cat.Count)</MudText>
<MudText Typo="Typo.body2" Color="Color.Error">@cat.Total.ToString("C")</MudText>
</MudStack>
}
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
@* Entries Detail *@
<MudExpansionPanels Class="mb-4">
<MudExpansionPanel Text="@($"Buchungen ({Report.Entries.Count})")" IsInitiallyExpanded="false">
<MudTable Items="Report.Entries" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Datum</MudTh>
<MudTh>Kategorie</MudTh>
<MudTh>Beschreibung</MudTh>
<MudTh>Person</MudTh>
<MudTh Style="text-align: right;">Betrag</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Datum">@context.BookingDate.ToString("dd.MM.yyyy")</MudTd>
<MudTd DataLabel="Kategorie">
<MudChip T="string" Size="Size.Small" Color="@GetEntryTypeColor(context.EntryType)">
@context.CategoryName
</MudChip>
</MudTd>
<MudTd DataLabel="Beschreibung">@context.Comment</MudTd>
<MudTd DataLabel="Person">@context.PersonName</MudTd>
<MudTd DataLabel="Betrag" Style="text-align: right;">
<MudText Color="@GetEntryTypeColor(context.EntryType)" Typo="Typo.body2">
@(context.EntryType == CashBookEntryType.Income ? "+" : "-")@context.Amount.ToString("C")
</MudText>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>Keine Buchungen im gewählten Zeitraum</MudText>
</NoRecordsContent>
</MudTable>
</MudExpansionPanel>
</MudExpansionPanels>
}
else if (!CashBookState.Value.IsLoading)
{
<MudAlert Severity="Severity.Info">
Wählen Sie einen Zeitraum und klicken Sie auf "Laden"
</MudAlert>
}
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" Href="/cashbook" Class="mt-4">
Zurück zum Kassenbuch
</MudButton>

View File

@ -0,0 +1,94 @@
using Koogle.Application.DTOs;
using Koogle.Domain.Enums;
using Koogle.Web.Store.CashBookState;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace Koogle.Web.Components.Pages.CashBook;
/// <summary>
/// Code-behind for CashBook Reports page.
/// </summary>
public partial class Reports
{
private int _selectedMonth = DateTime.Today.Month;
private int _selectedYear = DateTime.Today.Year;
private CashBookReportDto? Report => CashBookState.Value.Report;
private double[] IncomeChartData => Report?.IncomeByCategory
.OrderByDescending(c => c.Total)
.Select(c => (double)c.Total)
.ToArray() ?? [];
private string[] IncomeChartLabels => Report?.IncomeByCategory
.OrderByDescending(c => c.Total)
.Select(c => c.CategoryName)
.ToArray() ?? [];
private double[] ExpenseChartData => Report?.ExpenseByCategory
.OrderByDescending(c => c.Total)
.Select(c => (double)c.Total)
.ToArray() ?? [];
private string[] ExpenseChartLabels => Report?.ExpenseByCategory
.OrderByDescending(c => c.Total)
.Select(c => c.CategoryName)
.ToArray() ?? [];
protected override void OnInitialized()
{
base.OnInitialized();
// Auto-load current month report
LoadReport();
}
private void LoadReport()
{
var from = new DateTime(_selectedYear, _selectedMonth, 1);
var to = from.AddMonths(1).AddDays(-1);
Dispatcher.Dispatch(new LoadCashBookReportAction(from, to));
}
private void ClearError()
{
Dispatcher.Dispatch(new ClearCashBookErrorAction());
}
private static string GetMonthName(int month)
{
return month switch
{
1 => "Januar",
2 => "Februar",
3 => "März",
4 => "April",
5 => "Mai",
6 => "Juni",
7 => "Juli",
8 => "August",
9 => "September",
10 => "Oktober",
11 => "November",
12 => "Dezember",
_ => month.ToString()
};
}
private static Color GetEntryTypeColor(CashBookEntryType type)
{
return type == CashBookEntryType.Income ? Color.Success : Color.Error;
}
private void ExportExcel()
{
// Will be implemented in K16
Snackbar.Add("Excel-Export wird in einer späteren Version verfügbar sein", Severity.Info);
}
private void ExportPdf()
{
// Will be implemented in K17
Snackbar.Add("PDF-Export wird in einer späteren Version verfügbar sein", Severity.Info);
}
}