Compare commits
10 commits
d64b3cd876
...
0c48539ce6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c48539ce6 | |||
| 766295a119 | |||
| 69d3122243 | |||
| 562338d0c4 | |||
| 58ca05f83e | |||
| 577d63c0fa | |||
| 93bfcae2a0 | |||
| 70f9ce0d58 | |||
| 60f01d437c | |||
| e6efdb5f3a |
27 changed files with 437 additions and 57 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<FluentLayout>
|
<FluentLayout>
|
||||||
<FluentHeader Class="hidePrint">
|
<FluentHeader Class="hidePrint">
|
||||||
|
|
@ -9,7 +11,24 @@
|
||||||
<FluentBodyContent Class="body-content">
|
<FluentBodyContent Class="body-content">
|
||||||
<FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" />
|
<FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@Body
|
<ErrorBoundary @ref="errorBoundary">
|
||||||
|
<ChildContent>
|
||||||
|
@Body
|
||||||
|
</ChildContent>
|
||||||
|
<ErrorContent>
|
||||||
|
<FluentStack Orientation="Orientation.Vertical" Style="padding: 2rem; max-width: 640px;">
|
||||||
|
<FluentMessageBar Intent="MessageIntent.Error"
|
||||||
|
Title="Ein Fehler ist aufgetreten"
|
||||||
|
AllowDismiss="false">
|
||||||
|
Diese Seite konnte nicht angezeigt werden. Details stehen im Server-Log.
|
||||||
|
</FluentMessageBar>
|
||||||
|
<FluentStack Orientation="Orientation.Horizontal">
|
||||||
|
<FluentButton Appearance="Appearance.Neutral" OnClick="Recover">Neu versuchen</FluentButton>
|
||||||
|
<FluentAnchor Href="/" Appearance="Appearance.Neutral">Zur Startseite</FluentAnchor>
|
||||||
|
</FluentStack>
|
||||||
|
</FluentStack>
|
||||||
|
</ErrorContent>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</FluentBodyContent>
|
</FluentBodyContent>
|
||||||
</FluentStack>
|
</FluentStack>
|
||||||
|
|
@ -29,3 +48,22 @@
|
||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ErrorBoundary? errorBoundary;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
// ErrorBoundary state persists across NavigationManager.NavigateTo, so the
|
||||||
|
// error page would linger even after the user clicked "Zur Startseite".
|
||||||
|
// Reset on any location change.
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
|
||||||
|
=> errorBoundary?.Recover();
|
||||||
|
|
||||||
|
private void Recover() => errorBoundary?.Recover();
|
||||||
|
|
||||||
|
public void Dispose() => Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
9
Schaad.Accounting.UI/Components/Loading.razor
Normal file
9
Schaad.Accounting.UI/Components/Loading.razor
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@* Consistent placeholder for async-loaded pages/dialogs. Replaces the
|
||||||
|
ad-hoc `<p>Lädt…</p>` sprinkled across pages after PR P made every
|
||||||
|
OnInitializedAsync truly async. Kept as a pure visual — callers keep
|
||||||
|
their existing null-guard and just render this while data is loading. *@
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 2rem;">
|
||||||
|
<FluentProgressRing/>
|
||||||
|
<FluentLabel>Lädt…</FluentLabel>
|
||||||
|
</div>
|
||||||
|
|
@ -3,10 +3,13 @@
|
||||||
|
|
||||||
<PageTitle>Konten</PageTitle>
|
<PageTitle>Konten</PageTitle>
|
||||||
|
|
||||||
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
|
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
|
||||||
|
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
|
||||||
|
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
|
||||||
|
</FluentStack>
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
||||||
|
|
||||||
<FluentDataGrid Items="@accountQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" />
|
<PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" />
|
||||||
<PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" />
|
<PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" />
|
||||||
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" />
|
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" />
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,23 @@ public partial class Accounts : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<Account>? accountQueryable;
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Account> source = [];
|
||||||
|
private string filter = "";
|
||||||
|
|
||||||
|
private IQueryable<Account> Filtered =>
|
||||||
|
string.IsNullOrWhiteSpace(filter)
|
||||||
|
? source.AsQueryable()
|
||||||
|
: source.AsQueryable().Where(a =>
|
||||||
|
a.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
a.Number.ToString().Contains(filter) ||
|
||||||
|
(a.BankAccountNumber != null && a.BankAccountNumber.Contains(filter, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +48,8 @@ public partial class Accounts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +67,8 @@ public partial class Accounts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +80,8 @@ public partial class Accounts : ComponentBase
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await accountRepository.DeleteAccountAsync(id);
|
await accountRepository.DeleteAccountAsync(id);
|
||||||
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Konto gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
@page "/banktransactions"
|
@page "/banktransactions"
|
||||||
<PageTitle>BankTransactions</PageTitle>
|
<PageTitle>BankTransactions</PageTitle>
|
||||||
|
|
||||||
|
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
|
||||||
<FluentPaginator State="@pagination" />
|
<FluentPaginator State="@pagination" />
|
||||||
|
|
||||||
<div id="datagrid-container">
|
<div id="datagrid-container">
|
||||||
<FluentDataGrid Items="@TransactionList" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
|
||||||
<PropertyColumn Title="Buchungsdatum" Width="170px" Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending" />
|
<PropertyColumn Title="Buchungsdatum" Width="170px" Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending" />
|
||||||
<PropertyColumn Title="Valutadatum" Width="170px" Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true" />
|
<PropertyColumn Title="Valutadatum" Width="170px" Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true" />
|
||||||
<PropertyColumn Title="Text" Property="@(p => p.Text)" />
|
<PropertyColumn Title="Text" Property="@(p => p.Text)" />
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,22 @@ public partial class BankTransactions : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
|
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
|
||||||
private IQueryable<BankTransaction>? TransactionList;
|
|
||||||
|
private IReadOnlyList<BankTransaction> source = [];
|
||||||
|
private string filter = "";
|
||||||
|
|
||||||
|
private IQueryable<BankTransaction> Filtered =>
|
||||||
|
string.IsNullOrWhiteSpace(filter)
|
||||||
|
? source.AsQueryable()
|
||||||
|
: source.AsQueryable().Where(t =>
|
||||||
|
(t.Text != null && t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(t.Debtor != null && t.Debtor.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(t.Creditor != null && t.Creditor.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
t.Value.ToString().Contains(filter));
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
TransactionList = (await bankTransactionRepository.GetBankTransactionListAsync()).AsQueryable();
|
source = await bankTransactionRepository.GetBankTransactionListAsync();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@
|
||||||
|
|
||||||
<PageTitle>Buchungsregeln</PageTitle>
|
<PageTitle>Buchungsregeln</PageTitle>
|
||||||
|
|
||||||
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
|
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
|
||||||
|
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
|
||||||
|
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
|
||||||
|
</FluentStack>
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
||||||
|
|
||||||
<FluentDataGrid Items="@bookingRulesQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" />
|
<PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" />
|
||||||
<PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/>
|
<PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/>
|
||||||
<PropertyColumn Property="@(p => p.BookingText)" />
|
<PropertyColumn Property="@(p => p.BookingText)" />
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,23 @@ public partial class BookingRules : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<BookingRuleDataset>? bookingRulesQueryable;
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
|
|
||||||
|
private IReadOnlyList<BookingRuleDataset> source = [];
|
||||||
|
private string filter = "";
|
||||||
|
|
||||||
|
private IQueryable<BookingRuleDataset> Filtered =>
|
||||||
|
string.IsNullOrWhiteSpace(filter)
|
||||||
|
? source.AsQueryable()
|
||||||
|
: source.AsQueryable().Where(r =>
|
||||||
|
r.LookupText.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(r.BookingText != null && r.BookingText.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(r.Account != null && r.Account.Contains(filter, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +52,8 @@ public partial class BookingRules : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +71,8 @@ public partial class BookingRules : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +84,8 @@ public partial class BookingRules : ComponentBase
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await bookingRuleRepository.DeleteBookingRuleAsync(id);
|
await bookingRuleRepository.DeleteBookingRuleAsync(id);
|
||||||
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@
|
||||||
|
|
||||||
<PageTitle>Buchungstexte</PageTitle>
|
<PageTitle>Buchungstexte</PageTitle>
|
||||||
|
|
||||||
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
|
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
|
||||||
|
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
|
||||||
|
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
|
||||||
|
</FluentStack>
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
||||||
|
|
||||||
<FluentDataGrid Items="@bookingTextQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" />
|
<PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" />
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,20 @@ public partial class BookingTexts : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<BookingText>? bookingTextQueryable;
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
|
|
||||||
|
private IReadOnlyList<BookingText> source = [];
|
||||||
|
private string filter = "";
|
||||||
|
|
||||||
|
private IQueryable<BookingText> Filtered =>
|
||||||
|
string.IsNullOrWhiteSpace(filter)
|
||||||
|
? source.AsQueryable()
|
||||||
|
: source.AsQueryable().Where(t => t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +45,8 @@ public partial class BookingTexts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +64,8 @@ public partial class BookingTexts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +77,8 @@ public partial class BookingTexts : ComponentBase
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await bookingTextRepository.DeleteBookingTextAsync(id);
|
await bookingTextRepository.DeleteBookingTextAsync(id);
|
||||||
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungstext gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,7 @@
|
||||||
{
|
{
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,7 @@
|
||||||
{
|
{
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,7 @@
|
||||||
{
|
{
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,9 @@ public partial class Classes : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<SubClass>? subclassQueryable;
|
private IQueryable<SubClass>? subclassQueryable;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
|
@ -37,6 +40,7 @@ public partial class Classes : ComponentBase
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +59,7 @@ public partial class Classes : ComponentBase
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +72,7 @@ public partial class Classes : ComponentBase
|
||||||
{
|
{
|
||||||
await subclassRepository.DeleteSubClassAsync(id);
|
await subclassRepository.DeleteSubClassAsync(id);
|
||||||
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Klasse gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ public partial class AccountSelector : ComponentBase
|
||||||
|
|
||||||
private Account? SelectedAccount { get; set; }
|
private Account? SelectedAccount { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
// OnParametersSet, not OnInitialized: OnInitialized fires once, but callers now
|
||||||
|
// pass an empty Accounts list on first render and hydrate it after an await.
|
||||||
|
// OnParametersSet re-runs the lookup whenever Accounts or AccountId change, so the
|
||||||
|
// preselection tracks late-arriving data.
|
||||||
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
|
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
@if (editContext is null)
|
@if (editContext is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
@if (editContext is null)
|
@if (editContext is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</FluentButton>
|
</FluentButton>
|
||||||
<FluentButton Appearance="Appearance.Accent"
|
<FluentButton Appearance="Appearance.Accent"
|
||||||
|
Disabled="@(SelectedValue is null)"
|
||||||
OnClick="@SaveAsync">
|
OnClick="@SaveAsync">
|
||||||
Speichern
|
Speichern
|
||||||
</FluentButton>
|
</FluentButton>
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,14 @@ public partial class TransactionDialog : ComponentBase
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
|
if (editContext is null || SelectedValue is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
Content.ValueDate = SelectedValue!.Value;
|
Content.ValueDate = SelectedValue.Value;
|
||||||
Content.BookingDate = SelectedValue!.Value;
|
Content.BookingDate = SelectedValue.Value;
|
||||||
await transactionRepository.SaveTransactionAsync(Content);
|
await transactionRepository.SaveTransactionAsync(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
@if (editContext is null)
|
@if (editContext is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +73,15 @@
|
||||||
</table>
|
</table>
|
||||||
<div>
|
<div>
|
||||||
<FluentValidationSummary/>
|
<FluentValidationSummary/>
|
||||||
|
@if (validationErrors.Count > 0)
|
||||||
|
{
|
||||||
|
<ul class="validation-message">
|
||||||
|
@foreach (var err in validationErrors)
|
||||||
|
{
|
||||||
|
<li>@err</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
</FluentDialogBody>
|
</FluentDialogBody>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ public partial class TransactionSplitDialog : ComponentBase
|
||||||
private IReadOnlyList<AccountDataset> accounts = [];
|
private IReadOnlyList<AccountDataset> accounts = [];
|
||||||
private IReadOnlyList<string> bookingTexts = [];
|
private IReadOnlyList<string> bookingTexts = [];
|
||||||
private decimal openAmount = 0;
|
private decimal openAmount = 0;
|
||||||
|
private List<string> validationErrors = new();
|
||||||
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
|
@ -68,16 +69,43 @@ public partial class TransactionSplitDialog : ComponentBase
|
||||||
openAmount = Content.Value + transactionList.Sum(t => t.Value);
|
openAmount = Content.Value + transactionList.Sum(t => t.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every row in transactionList needs Text, TargetAccountId, and a non-zero Value.
|
||||||
|
// The single EditContext above only covers the first row (that's what it was
|
||||||
|
// constructed with) — so DataAnnotations validation misses subsequent split rows.
|
||||||
|
// Run explicit per-row checks here and surface everything in validationErrors.
|
||||||
|
private bool ValidateAllRows()
|
||||||
|
{
|
||||||
|
validationErrors.Clear();
|
||||||
|
for (var i = 0; i < transactionList.Count; i++)
|
||||||
|
{
|
||||||
|
var t = transactionList[i];
|
||||||
|
if (string.IsNullOrWhiteSpace(t.Text))
|
||||||
|
{
|
||||||
|
validationErrors.Add($"Zeile {i + 1}: Buchungstext fehlt");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(t.TargetAccountId))
|
||||||
|
{
|
||||||
|
validationErrors.Add($"Zeile {i + 1}: Konto fehlt");
|
||||||
|
}
|
||||||
|
if (t.Value == 0)
|
||||||
|
{
|
||||||
|
validationErrors.Add($"Zeile {i + 1}: Betrag darf nicht 0 sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validationErrors.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (!ValidateAllRows())
|
||||||
{
|
{
|
||||||
foreach (var transtaction in transactionList)
|
return;
|
||||||
{
|
|
||||||
await transactionRepository.SaveTransactionAsync(transtaction);
|
|
||||||
}
|
|
||||||
await Dialog.CloseAsync(Content);
|
|
||||||
}
|
}
|
||||||
|
foreach (var transtaction in transactionList)
|
||||||
|
{
|
||||||
|
await transactionRepository.SaveTransactionAsync(transtaction);
|
||||||
|
}
|
||||||
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CancelAsync()
|
private async Task CancelAsync()
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,6 @@ public partial class Home : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private ITransactionRepository transactionRepository { get; set; } = null!;
|
private ITransactionRepository transactionRepository { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
|
||||||
private NavigationManager Navigation { get; set; } = null!;
|
|
||||||
|
|
||||||
private List<Transaction>? matchingBankTransactions;
|
private List<Transaction>? matchingBankTransactions;
|
||||||
private IReadOnlyList<Account> accounts = [];
|
private IReadOnlyList<Account> accounts = [];
|
||||||
private IReadOnlyList<string> bookingTexts = [];
|
private IReadOnlyList<string> bookingTexts = [];
|
||||||
|
|
@ -45,11 +42,16 @@ public partial class Home : ComponentBase
|
||||||
Dictionary<int, string> Files = new();
|
Dictionary<int, string> Files = new();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
await base.OnInitializedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
{
|
{
|
||||||
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
|
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = await viewService.GetAccountViewListAsync();
|
||||||
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
|
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
|
||||||
await base.OnInitializedAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
|
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
|
||||||
|
|
@ -96,7 +98,10 @@ public partial class Home : ComponentBase
|
||||||
File.Delete(file.Value);
|
File.Delete(file.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
|
// Imports may update the bank-account balance and add new bank transactions
|
||||||
|
// — reload everything so the pending list, account balances, and booking-text
|
||||||
|
// suggestions stay consistent.
|
||||||
|
await LoadDataAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ImportXmlAndShowResultAsync(string fileName)
|
private async Task ImportXmlAndShowResultAsync(string fileName)
|
||||||
|
|
@ -174,14 +179,10 @@ public partial class Home : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
ReloadPage();
|
// Re-run the auto-matcher in-place so the newly created split
|
||||||
await Task.CompletedTask;
|
// transactions disappear from the pending list. No forceLoad — the
|
||||||
|
// SignalR circuit stays up and the scoped RepositoryCache survives.
|
||||||
|
await LoadDataAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReloadPage()
|
|
||||||
{
|
|
||||||
var uri = Navigation.Uri;
|
|
||||||
Navigation.NavigateTo(uri, forceLoad: true); // forceLoad = true does a full reload
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (balance is null)
|
@if (balance is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (balanceSheet is null)
|
@if (balanceSheet is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (accounts is null || transactions is null)
|
@if (accounts is null || transactions is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (accounts is null)
|
@if (accounts is null)
|
||||||
{
|
{
|
||||||
<p>Lädt…</p>
|
<Loading/>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ public partial class Transactions : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
|
|
||||||
private IReadOnlyList<AccountDataset> accounts = [];
|
private IReadOnlyList<AccountDataset> accounts = [];
|
||||||
|
|
||||||
private IQueryable<TransactionDataset>? transactionList;
|
private IQueryable<TransactionDataset>? transactionList;
|
||||||
|
|
@ -69,6 +72,7 @@ public partial class Transactions : ComponentBase
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Transaktion gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,18 +91,21 @@ public partial class Transactions : ComponentBase
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Transaktion gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var transaction = await transactionRepository.GetTransactionAsync(id);
|
var transaction = await transactionRepository.GetTransactionAsync(id);
|
||||||
var dialog = await dialogService.ShowConfirmationAsync($"Transaction '{transaction.Text}' mit Betrag {transaction.Value} wirklich löschen?", "Ja", "Nein", "Transaktion löschen");
|
var message = $"Transaktion '{transaction.Text}' mit Betrag {transaction.Value.ToFormattedString()} wirklich löschen?";
|
||||||
|
var dialog = await dialogService.ShowConfirmationAsync(message, "Ja", "Nein", "Transaktion löschen");
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await transactionRepository.DeleteTransactionAsync(id);
|
await transactionRepository.DeleteTransactionAsync(id);
|
||||||
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Transaktion gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
198
UI_IMPROVEMENT_PLAN.md
Normal file
198
UI_IMPROVEMENT_PLAN.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# UI improvement plan
|
||||||
|
|
||||||
|
Analysis produced 2026-07-03. Focused on Blazor UI concerns; the `IMPROVEMENT_PLAN.md` covered service/repository layers.
|
||||||
|
|
||||||
|
## 1. Real bugs
|
||||||
|
|
||||||
|
### 1.1 `TransactionDialog.SaveAsync` will NRE if the date picker is cleared
|
||||||
|
|
||||||
|
`TransactionDialog.razor.cs:37-40`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (editContext.Validate())
|
||||||
|
{
|
||||||
|
Content.ValueDate = SelectedValue!.Value; // SelectedValue is DateTime?
|
||||||
|
Content.BookingDate = SelectedValue!.Value;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SelectedValue!.Value` throws `InvalidOperationException` if the user opened the dialog, cleared the date, and hit Speichern.
|
||||||
|
|
||||||
|
### 1.2 `TransactionSplitDialog` only validates the first split row
|
||||||
|
|
||||||
|
`TransactionSplitDialog.razor.cs:50` builds an `EditContext` around the *first* transaction only. When the user adds a split row, subsequent rows have no `EditContext` — `editContext.Validate()` in `SaveAsync` returns true for the first row and completely ignores the rest. Empty text / wrong account on split rows never fails validation.
|
||||||
|
|
||||||
|
### 1.3 `AccountSelector` doesn't react to `Accounts` arriving late
|
||||||
|
|
||||||
|
`AccountSelector.razor.cs:17-20`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`OnInitialized` fires once. If the parent's `Accounts` list is empty at that point (the async path introduced in PR P) and hydrates later, `SelectedAccount` stays `null` and the typeahead shows no preselection. Should be `OnParametersSet`.
|
||||||
|
|
||||||
|
### 1.4 The whole "year/mandator via `forceLoad`" architecture
|
||||||
|
|
||||||
|
`MyHeader.YearChanged` calls `NavigateTo(..., forceLoad: true)`. `Home.SplitBankTransactionAsync` and `Home.ReloadPage()` do the same after any successful split. `SettingsService` is `Singleton` (documented in `Extensions.cs`) *because* the state has to survive that reload.
|
||||||
|
|
||||||
|
Symptoms:
|
||||||
|
|
||||||
|
- **Full HTTP round-trip** on every year/mandator switch, every split save. Tears down the SignalR circuit, blows away the scoped `RepositoryCache`, re-runs every repo constructor.
|
||||||
|
- **No URL-bookmarkable state** — you can't send yourself a link to "2023 / Mandator X".
|
||||||
|
- **Multi-user hazard** (already documented — single-user in practice, but the design is fragile if that ever changes).
|
||||||
|
- **Client-side state loss** — filters, scroll positions, half-typed inputs.
|
||||||
|
|
||||||
|
## 2. First-render null trap (partially patched, still fragile)
|
||||||
|
|
||||||
|
PR P's follow-up added `@if (accounts is null) { <p>Lädt…</p> return; }` sprinkled across ~11 pages/dialogs. Works but:
|
||||||
|
|
||||||
|
- Every page rolls its own guard. Sometimes it's `@if (balance is null)`, sometimes `@if (accounts is null || transactions is null)`, sometimes `@if (config is not null && layout is not null && data is not null) { <PlotlyChart .../> }`.
|
||||||
|
- No shared skeleton visual — just `<p>Lädt…</p>`. Feels unfinished.
|
||||||
|
- Collection fields are initialised to `[]` in some pages and left nullable in others. Two idioms in the same codebase.
|
||||||
|
|
||||||
|
A shared `<LoadingWhen Data="@x" />` wrapper with a consistent skeleton visual would centralize this.
|
||||||
|
|
||||||
|
## 3. `Home.razor` is doing too much
|
||||||
|
|
||||||
|
100 lines of markup + 190-line codebehind for four unrelated concerns on the landing page:
|
||||||
|
|
||||||
|
1. **Statement upload** — `FluentInputFile`, progress bar, cancel button.
|
||||||
|
2. **XML/zip file processing** — `OnCompletedAsync`, unzip, iterate.
|
||||||
|
3. **Auto-matched bank transactions table** — hand-crafted `<table>`, `<input list="texts">` for autocomplete, per-row book / split buttons.
|
||||||
|
4. **Booking / split flow** — `BookBankTransactionAsync`, `SplitBankTransactionAsync`.
|
||||||
|
|
||||||
|
Split into `<StatementImport />` and `<PendingBookings />`; Home stacks them.
|
||||||
|
|
||||||
|
## 4. Reports vs. list pages use different visual languages
|
||||||
|
|
||||||
|
- **List pages** (Accounts, BookingRules, BookingTexts, Transactions, BankTransactions): `<FluentDataGrid>` with `<PropertyColumn>` / `<TemplateColumn>`, pagination, sortable headers.
|
||||||
|
- **Reports** (BalanceReport, BalanceSheetReport, DetailReport, ProfitLossReport): hand-rolled `<table class="report">` with `@if (account.Balance == 0) { continue; }` inside `<tr>` and hand-crafted section headers.
|
||||||
|
|
||||||
|
Two different visual languages. Report format is domain-appropriate (they're printed accounting statements, not grids), but the hand-rolled markup means:
|
||||||
|
|
||||||
|
- Adding a column means editing 4 report `.razor` files.
|
||||||
|
- The `@if ... { continue; }` pattern inside `<tr>` reads oddly.
|
||||||
|
- No component reuse across reports.
|
||||||
|
|
||||||
|
Extract `<ReportSection Title="…">`, `<ReportAccountRow Account="@x" ShowBalance />`, `<ReportTotalRow Label="…" Value="…" />`.
|
||||||
|
|
||||||
|
## 5. No error boundaries, no expected-error UX
|
||||||
|
|
||||||
|
If any component throws, users get `blazor-error-ui` at the bottom of `MainLayout.razor` — a fixed div with *"An unhandled error has occurred. Reload."* No route back except manual reload. `Error.razor` is the default template that tells the user to enable Development mode.
|
||||||
|
|
||||||
|
For expected failures (save failed because file locked, unknown id on delete):
|
||||||
|
|
||||||
|
- Wrap each page/section in `<ErrorBoundary>` with a friendly retry.
|
||||||
|
- Route repository/service exceptions through `IMessageService` / `IToastService` (both already registered) instead of letting them bubble.
|
||||||
|
|
||||||
|
## 6. Feedback after mutations is invisible
|
||||||
|
|
||||||
|
`Accounts.EditAsync` closes the dialog and re-fetches the list. No confirmation, no "Saved" toast. If the list is long and the user's edit isn't visible in the current page, they have no signal the save worked.
|
||||||
|
|
||||||
|
Same in `BookingRules`, `BookingTexts`, `Classes`, `Transactions`.
|
||||||
|
|
||||||
|
Add `toastService.ShowSuccess("Konto gespeichert")` etc.
|
||||||
|
|
||||||
|
## 7. Confirmation dialogs show unformatted values
|
||||||
|
|
||||||
|
`Transactions.DeleteAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
$"Transaction '{transaction.Text}' mit Betrag {transaction.Value} wirklich löschen?"
|
||||||
|
```
|
||||||
|
|
||||||
|
`transaction.Value` is a raw decimal — no currency, no thousands separator. Reads *"...mit Betrag 1234.567 wirklich löschen?"*. Should use `.ToFormattedString()` and append the currency.
|
||||||
|
|
||||||
|
Same in `Accounts.DeleteAsync`, `BookingRules.DeleteAsync`, `BookingTexts.DeleteAsync`, `Classes.DeleteAsync`.
|
||||||
|
|
||||||
|
## 8. No search / filter on list pages
|
||||||
|
|
||||||
|
None of the FluentDataGrid pages have search or filter controls — only pagination.
|
||||||
|
|
||||||
|
- BookingRules can grow to hundreds of entries.
|
||||||
|
- BookingTexts likewise.
|
||||||
|
- BankTransactions runs into thousands over a year.
|
||||||
|
|
||||||
|
`<FluentSearch>` bound to a `filter` field, filter the `IQueryable` before passing to the grid.
|
||||||
|
|
||||||
|
## 9. Dialogs are fixed-height
|
||||||
|
|
||||||
|
`DialogParameters { Height = "500px" }` on every dialog. Content shorter than 500px wastes space; content longer scrolls inside. Should be `Height = "auto"`.
|
||||||
|
|
||||||
|
Also every dialog uses the same header icon (`Icons.Regular.Size24.WindowApps` — a generic "window" glyph). No visual distinction between dialog types.
|
||||||
|
|
||||||
|
## 10. `AccountSelector` uses `Autofocus="true"`
|
||||||
|
|
||||||
|
Fine for the split-dialog flow. In the Home booking table it means every keystroke-navigated row grabs focus from wherever the user was. Should be an opt-in parameter, off by default.
|
||||||
|
|
||||||
|
## 11. Header year/mandator has no confirm on change
|
||||||
|
|
||||||
|
`MyHeader.YearChanged` immediately does a `forceLoad`. If the user accidentally clicks 2018 in the dropdown, whatever they were doing (unsaved dialog, half-typed booking) is gone. Fixed once (1.4) is fixed.
|
||||||
|
|
||||||
|
## 12. Minor rough edges
|
||||||
|
|
||||||
|
- `Home.razor.cs` field name `IsCanceled` describes an intent, not a state — `cancelRequested` would be clearer.
|
||||||
|
- `Home.razor:35` shows a smiley emoji when no pending bookings.
|
||||||
|
- `Home.razor:67` uses `<input list="texts">` + `<datalist>` for text autocomplete — native HTML, inconsistent with `FluentAutocomplete` used in `AccountSelector`.
|
||||||
|
- Print CSS in `app.css` only hides `.hidePrint`. No `@page`, no `page-break-inside: avoid` on report rows. Long reports fragment across pages randomly.
|
||||||
|
- No accessible label on the delete/edit icon buttons (screen readers say "Button, button").
|
||||||
|
- The header ` $"{title} {mandator} {year}"` in `Report.cs` reads like *"Bilanz Claudio Schaad 2026"* — no separator, no branding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested phased plan
|
||||||
|
|
||||||
|
Sequenced by value / risk. Each is one PR unless noted.
|
||||||
|
|
||||||
|
### Phase 1 — Correctness bugs (low risk, high value)
|
||||||
|
|
||||||
|
- **U-1** Fix `AccountSelector` to use `OnParametersSet` instead of `OnInitialized` so the preselection tracks late-arriving `Accounts`.
|
||||||
|
- **U-2** Guard `TransactionDialog.SaveAsync` against `SelectedValue == null`; disable Save when the date is empty.
|
||||||
|
- **U-3** Fix `TransactionSplitDialog` validation to cover every row (per-row `EditContext`, or one `EditContext` over a wrapper).
|
||||||
|
|
||||||
|
### Phase 2 — The forceLoad/singleton trap (biggest single improvement)
|
||||||
|
|
||||||
|
Split into two PRs on delivery because the header path touches ~15 pages.
|
||||||
|
|
||||||
|
- **U-4a** Fix `Home.ReloadPage()` — the single non-settings `forceLoad` in the app. `SplitBankTransactionAsync` and `OnCompletedAsync` (statement import) now call a shared `LoadDataAsync()` that re-runs the auto-matcher, re-fetches accounts and booking texts in place, and keeps the SignalR circuit + scoped `RepositoryCache` alive. No cross-cutting scaffolding needed.
|
||||||
|
- **U-4b** Persist year and mandator (cookie + URL query params), make `SettingsService` `Scoped`, add an event or `LocationChanged` subscription so every page refreshes on year/mandator change, and drop `MyHeader`'s `forceLoad`. This is the big one — it touches every routable page.
|
||||||
|
|
||||||
|
### Phase 3 — Loading and error UX consistency
|
||||||
|
|
||||||
|
- **U-5** Shared `<LoadingWhen Data="@x">` component with a consistent skeleton visual. Adopt across every page currently using ad-hoc `@if (x is null) { <p>Lädt…</p> return; }`.
|
||||||
|
- **U-6a** Wrap `MainLayout`'s `@Body` in `<ErrorBoundary>` with a friendly retry UI. Reset on any `NavigationManager.LocationChanged` so the error state doesn't stick after the user clicks "Zur Startseite".
|
||||||
|
- **U-6b** Route expected `SaveTransactionAsync` / `DeleteAccountAsync` / etc. exceptions to `IMessageService` — try/catch in each of the ~15 event handlers with a shared error-formatting helper.
|
||||||
|
|
||||||
|
### Phase 4 — Feedback + polish for mutations
|
||||||
|
|
||||||
|
- **U-7** `IToastService.ShowSuccess(...)` after every successful save/delete across Accounts, BookingRules, BookingTexts, Classes, Transactions.
|
||||||
|
- **U-8** Format the raw decimal in Transactions' delete confirmation via `ToFormattedString()`, and fix the "Transaction" → "Transaktion" typo. Turned out to be a one-file change — the other four CRUD pages don't put values into confirmations.
|
||||||
|
- **U-9** Add `<FluentSearch>` filter to Accounts, BookingRules, BookingTexts, BankTransactions.
|
||||||
|
|
||||||
|
### Phase 5 — Home page split
|
||||||
|
|
||||||
|
- **U-10** Extract `<StatementImport />` and `<PendingBookings />` components. Home stacks them.
|
||||||
|
- **U-11** Replace the Home `<input list="texts">` + `<datalist>` with `FluentAutocomplete` so the autocomplete UI is uniform with the split dialog.
|
||||||
|
|
||||||
|
### Phase 6 — Report componentization
|
||||||
|
|
||||||
|
- **U-12** Extract `<ReportSection>`, `<ReportAccountRow>`, `<ReportSectionHeader>`, `<ReportTotalRow>`, `<ReportFooter>`. Apply to the four report pages. Shared print CSS + `page-break-inside: avoid` on rows.
|
||||||
|
|
||||||
|
### Phase 7 — Nice-to-haves (optional)
|
||||||
|
|
||||||
|
- Accessibility: `aria-label` on icon-only buttons, focus management after dialog close, skip-to-content link.
|
||||||
|
- Dark mode toggle (Fluent supports theme override).
|
||||||
|
- Keyboard: `Ctrl+S` in dialogs, `Del` on selected row in grids.
|
||||||
|
- Header format: nicer separator + optional subtitle.
|
||||||
|
- `AccountSelector`'s `Autofocus` becomes opt-in.
|
||||||
|
|
||||||
|
## What was explicitly *not* flagged
|
||||||
|
|
||||||
|
- The `de-CH` culture hardcode. Correct choice for the target user; localizing is a lot of work for zero user value.
|
||||||
|
- The `InteractiveServer` render mode. Correct for this app.
|
||||||
|
- The Fluent UI dependency.
|
||||||
Loading…
Reference in a new issue