Compare commits

..

10 commits

Author SHA1 Message Date
0c48539ce6 PR U-9: FluentSearch filter on grid pages
Accounts, BookingRules, BookingTexts, BankTransactions each get an
<FluentSearch> above the grid, backed by a computed Filtered
IQueryable over an in-memory source list. FluentSearch is Immediate,
so filtering happens on each keystroke; data sizes are small enough
for per-render LINQ.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:10:43 +02:00
766295a119 PR U-8: format the Transactions delete-confirmation amount
Route transaction.Value through ToFormattedString() so the confirmation
reads "Betrag 1'234.57" instead of "Betrag 1234.567", and fix the
"Transaction" → "Transaktion" typo. One-file change — the other four
CRUD pages don't put values into confirmations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:06:44 +02:00
69d3122243 PR U-7: success toasts after mutations
Add IToastService.ShowToast(ToastIntent.Success, ...) after each
successful save/delete across Accounts, BookingRules, BookingTexts,
Classes, and Transactions. Toasts fire after the list re-fetch so
the confirmation is visible even if the edited item paginated out of
view.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:51:38 +02:00
562338d0c4 PR U-6a: MainLayout wraps @Body in an ErrorBoundary
Add a Blazor built-in ErrorBoundary around @Body with a friendly
FluentMessageBar + retry / home buttons. Also hook
NavigationManager.LocationChanged so the boundary resets on any nav
(otherwise the error state persists across NavigateTo and "Zur
Startseite" still shows the failure banner).

U-6 split into U-6a (this) and U-6b (per-handler try/catch to route
expected exceptions to IMessageService — deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:47:14 +02:00
58ca05f83e PR U-5: shared Loading component
Add Components/Loading.razor and swap the ad-hoc `<p>Lädt…</p>` guard
visuals for it across the four reports, three edit dialogs, and three
chart pages (the last three now show the placeholder instead of a
blank page while waiting for view data). Kept as a pure visual so
each call site changes from `<p>Lädt…</p>` to `<Loading/>` with no
other churn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:44:23 +02:00
577d63c0fa PR U-4a: Home refreshes in place instead of forceLoad
SplitBankTransactionAsync ended with NavigateTo(forceLoad: true), which
tore down the SignalR circuit and re-ran every repo constructor just
to refresh the pending-bookings list. Extract data loading into a
LoadDataAsync helper called from OnInitializedAsync, SplitBank
TransactionAsync, and OnCompletedAsync — the page refreshes in-circuit,
scoped caches survive, and the NavigationManager injection can go.

Also flags the plan: U-4 is split into U-4a (this) and U-4b (persist
settings + drop MyHeader's forceLoad, touching ~15 pages).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:39:03 +02:00
93bfcae2a0 PR U-3: validate every row in TransactionSplitDialog
The single EditContext only wrapped the first split row; subsequent
rows added by AddTransactionAsync silently bypassed
DataAnnotationsValidator. Replace editContext.Validate() with an
explicit ValidateAllRows() loop that checks Text, TargetAccountId, and
Value != 0 for every row, and render the collected messages under the
existing FluentValidationSummary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:36:21 +02:00
70f9ce0d58 PR U-2: guard TransactionDialog.SaveAsync against a cleared date
Guard SaveAsync against a null SelectedValue (previously
SelectedValue!.Value threw InvalidOperationException) and disable
the Save button while the date is empty so the user gets a visible
cue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:32:34 +02:00
60f01d437c PR U-1: AccountSelector reacts to Accounts arriving late
Move the SelectedAccount = Accounts.FirstOrDefault(...) lookup from
OnInitialized to OnParametersSet so the preselection tracks the
Accounts list arriving asynchronously (every caller now passes []
first and hydrates after an await). Idempotent — the OnSelectedChanged
callback path returns the same AccountId back and the parent re-render
lookup finds the same account.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 19:17:41 +02:00
e6efdb5f3a Add UI improvement plan
UI-focused analysis complementing IMPROVEMENT_PLAN.md: 4 real bugs,
first-render null-guard inconsistency, Home page overload, report /
list-page visual-language split, missing error boundaries, missing
mutation feedback, missing search on grid pages, and a handful of
minor rough edges. Phased into 7 phases (U-1 .. U-12 plus
nice-to-haves) sequenced by value / risk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 19:16:47 +02:00
27 changed files with 437 additions and 57 deletions

View file

@ -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">
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body @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;
}

View 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>

View file

@ -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" />

View file

@ -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);
} }
} }
} }

View file

@ -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)" />

View file

@ -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();
} }
} }

View file

@ -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)" />

View file

@ -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);
} }
} }
} }

View file

@ -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))" />

View file

@ -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);
} }
} }
} }

View file

@ -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/>
}

View file

@ -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/>
}

View file

@ -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/>
}

View file

@ -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);
} }
} }
} }

View file

@ -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);
} }

View file

@ -14,7 +14,7 @@
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null) @if (editContext is null)
{ {
<p>Lädt…</p> <Loading/>
return; return;
} }

View file

@ -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>

View file

@ -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);
} }

View file

@ -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>

View file

@ -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,17 +69,44 @@ 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())
{ {
return;
}
foreach (var transtaction in transactionList) foreach (var transtaction in transactionList)
{ {
await transactionRepository.SaveTransactionAsync(transtaction); await transactionRepository.SaveTransactionAsync(transtaction);
} }
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
}
private async Task CancelAsync() private async Task CancelAsync()
{ {

View file

@ -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
}
} }

View file

@ -2,7 +2,7 @@
@if (balance is null) @if (balance is null)
{ {
<p>Lädt…</p> <Loading/>
return; return;
} }

View file

@ -2,7 +2,7 @@
@if (balanceSheet is null) @if (balanceSheet is null)
{ {
<p>Lädt…</p> <Loading/>
return; return;
} }

View file

@ -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;
} }

View file

@ -2,7 +2,7 @@
@if (accounts is null) @if (accounts is null)
{ {
<p>Lädt…</p> <Loading/>
return; return;
} }

View file

@ -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
View 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.