Compare commits
No commits in common. "0c48539ce61db58033f773ee0fc7a4d5b6fa6663" and "d64b3cd876c181da26a7d2cfb8ce4c37fd2377e0" have entirely different histories.
0c48539ce6
...
d64b3cd876
27 changed files with 57 additions and 437 deletions
|
|
@ -1,6 +1,4 @@
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@implements IDisposable
|
|
||||||
|
|
||||||
<FluentLayout>
|
<FluentLayout>
|
||||||
<FluentHeader Class="hidePrint">
|
<FluentHeader Class="hidePrint">
|
||||||
|
|
@ -11,24 +9,7 @@
|
||||||
<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">
|
@Body
|
||||||
<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>
|
||||||
|
|
@ -48,22 +29,3 @@
|
||||||
<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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
@* 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,13 +3,10 @@
|
||||||
|
|
||||||
<PageTitle>Konten</PageTitle>
|
<PageTitle>Konten</PageTitle>
|
||||||
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
|
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
|
||||||
<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="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@accountQueryable" 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,23 +14,11 @@ public partial class Accounts : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
private IQueryable<Account>? accountQueryable;
|
||||||
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()
|
||||||
{
|
{
|
||||||
source = await accountRepository.GetAccountListAsync();
|
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,8 +36,7 @@ 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)
|
||||||
{
|
{
|
||||||
source = await accountRepository.GetAccountListAsync();
|
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,8 +54,7 @@ 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)
|
||||||
{
|
{
|
||||||
source = await accountRepository.GetAccountListAsync();
|
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,8 +66,7 @@ public partial class Accounts : ComponentBase
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await accountRepository.DeleteAccountAsync(id);
|
await accountRepository.DeleteAccountAsync(id);
|
||||||
source = await accountRepository.GetAccountListAsync();
|
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Konto gelöscht", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
@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="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
|
<FluentDataGrid Items="@TransactionList" 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,22 +8,11 @@ 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()
|
||||||
{
|
{
|
||||||
source = await bankTransactionRepository.GetBankTransactionListAsync();
|
TransactionList = (await bankTransactionRepository.GetBankTransactionListAsync()).AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,10 @@
|
||||||
|
|
||||||
<PageTitle>Buchungsregeln</PageTitle>
|
<PageTitle>Buchungsregeln</PageTitle>
|
||||||
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
|
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
|
||||||
<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="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@bookingRulesQueryable" 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,23 +18,11 @@ public partial class BookingRules : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
private IQueryable<BookingRuleDataset>? bookingRulesQueryable;
|
||||||
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()
|
||||||
{
|
{
|
||||||
source = await viewService.GetBookingRuleViewListAsync();
|
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,8 +40,7 @@ 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)
|
||||||
{
|
{
|
||||||
source = await viewService.GetBookingRuleViewListAsync();
|
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,8 +58,7 @@ 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)
|
||||||
{
|
{
|
||||||
source = await viewService.GetBookingRuleViewListAsync();
|
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,8 +70,7 @@ public partial class BookingRules : ComponentBase
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await bookingRuleRepository.DeleteBookingRuleAsync(id);
|
await bookingRuleRepository.DeleteBookingRuleAsync(id);
|
||||||
source = await viewService.GetBookingRuleViewListAsync();
|
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gelöscht", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,10 @@
|
||||||
|
|
||||||
<PageTitle>Buchungstexte</PageTitle>
|
<PageTitle>Buchungstexte</PageTitle>
|
||||||
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
|
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
|
||||||
<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="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@bookingTextQueryable" 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,20 +14,11 @@ public partial class BookingTexts : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
private IQueryable<BookingText>? bookingTextQueryable;
|
||||||
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()
|
||||||
{
|
{
|
||||||
source = await bookingTextRepository.GetBookingTextListAsync();
|
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,8 +36,7 @@ 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)
|
||||||
{
|
{
|
||||||
source = await bookingTextRepository.GetBookingTextListAsync();
|
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,8 +54,7 @@ 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)
|
||||||
{
|
{
|
||||||
source = await bookingTextRepository.GetBookingTextListAsync();
|
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,8 +66,7 @@ public partial class BookingTexts : ComponentBase
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
await bookingTextRepository.DeleteBookingTextAsync(id);
|
await bookingTextRepository.DeleteBookingTextAsync(id);
|
||||||
source = await bookingTextRepository.GetBookingTextListAsync();
|
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
||||||
toastService.ShowToast(ToastIntent.Success, "Buchungstext gelöscht", 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,3 @@
|
||||||
{
|
{
|
||||||
<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,7 +7,3 @@
|
||||||
{
|
{
|
||||||
<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,7 +7,3 @@
|
||||||
{
|
{
|
||||||
<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,9 +14,6 @@ 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()
|
||||||
|
|
@ -40,7 +37,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +55,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +67,6 @@ 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,11 +14,7 @@ public partial class AccountSelector : ComponentBase
|
||||||
|
|
||||||
private Account? SelectedAccount { get; set; }
|
private Account? SelectedAccount { get; set; }
|
||||||
|
|
||||||
// OnParametersSet, not OnInitialized: OnInitialized fires once, but callers now
|
protected override void OnInitialized()
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
@if (editContext is null)
|
@if (editContext is null)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +56,6 @@
|
||||||
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,14 +34,10 @@ 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)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,15 +73,6 @@
|
||||||
</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,7 +36,6 @@ 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()
|
||||||
|
|
@ -69,43 +68,16 @@ 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 (!ValidateAllRows())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
return;
|
foreach (var transtaction in transactionList)
|
||||||
|
{
|
||||||
|
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,6 +31,9 @@ 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 = [];
|
||||||
|
|
@ -42,16 +45,11 @@ 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)
|
||||||
|
|
@ -98,10 +96,7 @@ public partial class Home : ComponentBase
|
||||||
File.Delete(file.Value);
|
File.Delete(file.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imports may update the bank-account balance and add new bank transactions
|
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
|
||||||
// — 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)
|
||||||
|
|
@ -179,10 +174,14 @@ 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)
|
||||||
{
|
{
|
||||||
// Re-run the auto-matcher in-place so the newly created split
|
ReloadPage();
|
||||||
// transactions disappear from the pending list. No forceLoad — the
|
await Task.CompletedTask;
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (balanceSheet is null)
|
@if (balanceSheet is null)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (accounts is null || transactions is null)
|
@if (accounts is null || transactions is null)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if (accounts is null)
|
@if (accounts is null)
|
||||||
{
|
{
|
||||||
<Loading/>
|
<p>Lädt…</p>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ 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;
|
||||||
|
|
@ -72,7 +69,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,21 +87,18 @@ 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 message = $"Transaktion '{transaction.Text}' mit Betrag {transaction.Value.ToFormattedString()} wirklich löschen?";
|
var dialog = await dialogService.ShowConfirmationAsync($"Transaction '{transaction.Text}' mit Betrag {transaction.Value} wirklich löschen?", "Ja", "Nein", "Transaktion 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# 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