PR U-10: split Home into StatementImport and PendingBookings

Home shrinks from ~200-line codebehind + 100-line markup to a
10-line orchestrator holding a @ref to <PendingBookings/> and
wiring <StatementImport OnImported="RefreshPendingAsync"/> to it.

Also renames IsCanceled -> cancelRequested and clears the Files
dictionary after each import batch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-07-06 16:29:51 +02:00
parent 0c48539ce6
commit 97bf85d6a8
6 changed files with 315 additions and 272 deletions

View file

@ -1,102 +1,9 @@
@page "/" @page "/"
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Home</PageTitle> <PageTitle>Home</PageTitle>
<FluentInputFile @ref="@myFileByBuffer" <StatementImport OnImported="RefreshPendingAsync"/>
AnchorId="MyUploadBuffer"
DragDropZoneVisible="false"
Mode="InputFileMode.Buffer"
Multiple="true"
MaximumFileSize="@(10 * 1024 * 1024)"
Accept=".zip, .xml"
OnProgressChange="@OnProgressChangeAsync"
OnCompleted="@OnCompletedAsync"/>
<FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent"/> <PendingBookings @ref="pendingBookings"/>
<FluentLabel Alignment="HorizontalAlignment.Center">
@progressTitle
</FluentLabel>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.ArrowUpload())"
Id="MyUploadBuffer">
Upload files
</FluentButton>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.Dismiss())"
@onclick="@((e) => IsCanceled = true)">
Cancel
</FluentButton>
@if (matchingBankTransactions == null || matchingBankTransactions.Count == 0)
{
<p>Keine Transaktionen vorhanden!</p>
<FluentEmoji Value="@(new Emojis.SmileysEmotion.Color.Default.MoneyMouthFace())" />
}
else
{
<table>
<thead>
<tr>
<th>Buchungsdatum</th>
<th>Valutadatum</th>
<th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th>Betrag</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in matchingBankTransactions)
{
<tr>
<td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..." readonly="@transaction.ReadOnly"/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
@if (transaction.Value > 0)
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
}
else
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td>
<td>
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0 || transaction.ReadOnly)" />
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
OnClick="@(() => BookBankTransactionAsync(transaction))"
Disabled="@transaction.ReadOnly" />
</td>
</tr>
}
</tbody>
</table>
}

View file

@ -1,188 +1,16 @@
using System.IO.Compression;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages; namespace Schaad.Accounting.UI.Components.Pages;
public partial class Home : ComponentBase public partial class Home : ComponentBase
{ {
[Inject] private PendingBookings? pendingBookings;
private IViewService viewService { get; set; } = null!;
[Inject] private async Task RefreshPendingAsync()
private IToastService toastService { get; set; } = null!;
[Inject]
private IMessageService messageService { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IFileService fileService { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
FluentInputFile? myFileByBuffer;
int? progressPercent;
string? progressTitle;
bool IsCanceled;
Dictionary<int, string> Files = new();
protected override async Task OnInitializedAsync()
{ {
await LoadDataAsync(); if (pendingBookings is not null)
await base.OnInitializedAsync();
}
private async Task LoadDataAsync()
{
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
accounts = await viewService.GetAccountViewListAsync();
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
}
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
{
progressPercent = file.ProgressPercent;
progressTitle = file.ProgressTitle;
// To cancel?
file.IsCancelled = IsCanceled;
// New file
if (!Files.ContainsKey(file.Index))
{ {
var localFile = Path.GetTempFileName() + file.Name; await pendingBookings.RefreshAsync();
Files.Add(file.Index, localFile);
}
// Write to the FileStream
await file.Buffer.AppendToFileAsync(Files[file.Index]);
}
private async Task OnCompletedAsync(IEnumerable<FluentInputFileEventArgs> files)
{
progressPercent = myFileByBuffer!.ProgressPercent;
progressTitle = myFileByBuffer!.ProgressTitle;
foreach (var file in Files)
{
var extension = Path.GetExtension(file.Value);
if (extension is ".xml")
{
await ImportXmlAndShowResultAsync(file.Value);
}
else if (extension is ".zip")
{
var unzippedFiles = UnzipAndListFiles(file.Value);
foreach (var unzippedFile in unzippedFiles)
{
await ImportXmlAndShowResultAsync(unzippedFile);
File.Delete(unzippedFile);
}
}
File.Delete(file.Value);
}
// 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)
{
var messages = await fileService.ImportAccountStatementFileAsync(fileName);
foreach (var message in messages)
{
await ShowImportResultAsync(message);
}
}
private IReadOnlyList<string> UnzipAndListFiles(string zipFilePath)
{
var tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
Directory.CreateDirectory(tempExtractPath);
ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath);
return new List<string>(Directory.GetFiles(tempExtractPath, "*", SearchOption.AllDirectories));
}
private async Task ShowImportResultAsync(MessageDataset message)
{
var joinedDetails = string.Join(", ", message.Lines);
var text = $"{message.Title}: {joinedDetails}";
if (message.Status == MessageStatus.Success)
{
ShowToast(text, ToastIntent.Success);
}
else if (message.Status == MessageStatus.Info)
{
ShowToast(text, ToastIntent.Info);
}
else if (message.Status == MessageStatus.Warning)
{
await ShowMessageAsync(text, MessageIntent.Warning);
}
else if (message.Status == MessageStatus.Error)
{
await ShowMessageAsync(text, MessageIntent.Error);
}
}
private async Task ShowMessageAsync(string message, MessageIntent intent)
{
await messageService.ShowMessageBarAsync(message, intent, "MESSAGES_TOP");
}
private void ShowToast(string message, ToastIntent intent)
{
toastService.ShowToast(intent, message, 3000);
}
private async Task BookBankTransactionAsync(Transaction transaction)
{
if (string.IsNullOrEmpty(transaction.Text) || string.IsNullOrEmpty(transaction.TargetAccountId))
{
ShowToast("Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", ToastIntent.Error);
return;
}
await transactionRepository.SaveTransactionAsync(transaction);
transaction.ReadOnly = true;
}
private async Task SplitBankTransactionAsync(Transaction transaction)
{
var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
{
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
// Re-run the auto-matcher in-place so the newly created split
// transactions disappear from the pending list. No forceLoad — the
// SignalR circuit stays up and the scoped RepositoryCache survives.
await LoadDataAsync();
} }
} }
} }

View file

@ -0,0 +1,73 @@
@rendermode InteractiveServer
@if (matchingBankTransactions is null || matchingBankTransactions.Count == 0)
{
<p>Keine Transaktionen vorhanden!</p>
<FluentEmoji Value="@(new Emojis.SmileysEmotion.Color.Default.MoneyMouthFace())" />
}
else
{
<table>
<thead>
<tr>
<th>Buchungsdatum</th>
<th>Valutadatum</th>
<th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th>Betrag</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in matchingBankTransactions)
{
<tr>
<td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..." readonly="@transaction.ReadOnly"/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
@if (transaction.Value > 0)
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
}
else
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td>
<td>
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0 || transaction.ReadOnly)" />
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
OnClick="@(() => BookBankTransactionAsync(transaction))"
Disabled="@transaction.ReadOnly" />
</td>
</tr>
}
</tbody>
</table>
}

View file

@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages;
public partial class PendingBookings : ComponentBase
{
[Inject]
private IViewService viewService { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
await base.OnInitializedAsync();
}
/// <summary>
/// Re-run the auto-matcher and reload accounts / booking texts. Called from
/// the parent (Home) after a statement import completes.
/// </summary>
public async Task RefreshAsync()
{
await LoadDataAsync();
StateHasChanged();
}
private async Task LoadDataAsync()
{
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
accounts = await viewService.GetAccountViewListAsync();
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
}
private async Task BookBankTransactionAsync(Transaction transaction)
{
if (string.IsNullOrEmpty(transaction.Text) || string.IsNullOrEmpty(transaction.TargetAccountId))
{
toastService.ShowToast(ToastIntent.Error, "Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", 3000);
return;
}
await transactionRepository.SaveTransactionAsync(transaction);
transaction.ReadOnly = true;
}
private async Task SplitBankTransactionAsync(Transaction transaction)
{
var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
{
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
await LoadDataAsync();
}
}
}

View file

@ -0,0 +1,26 @@
@rendermode InteractiveServer
<FluentInputFile @ref="@myFileByBuffer"
AnchorId="MyUploadBuffer"
DragDropZoneVisible="false"
Mode="InputFileMode.Buffer"
Multiple="true"
MaximumFileSize="@(10 * 1024 * 1024)"
Accept=".zip, .xml"
OnProgressChange="@OnProgressChangeAsync"
OnCompleted="@OnCompletedAsync"/>
<FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent"/>
<FluentLabel Alignment="HorizontalAlignment.Center">
@progressTitle
</FluentLabel>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.ArrowUpload())"
Id="MyUploadBuffer">
Upload files
</FluentButton>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.Dismiss())"
@onclick="@((e) => cancelRequested = true)">
Cancel
</FluentButton>

View file

@ -0,0 +1,128 @@
using System.IO.Compression;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages;
public partial class StatementImport : ComponentBase
{
[Inject]
private IFileService fileService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
[Inject]
private IMessageService messageService { get; set; } = null!;
/// <summary>
/// Fires after all uploaded files have been processed, so the parent can
/// refresh dependent views (e.g. re-run the auto-matcher on Home).
/// </summary>
[Parameter]
public EventCallback OnImported { get; set; }
private FluentInputFile? myFileByBuffer;
private int? progressPercent;
private string? progressTitle;
private bool cancelRequested;
private Dictionary<int, string> Files = new();
private async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
{
progressPercent = file.ProgressPercent;
progressTitle = file.ProgressTitle;
file.IsCancelled = cancelRequested;
if (!Files.ContainsKey(file.Index))
{
var localFile = Path.GetTempFileName() + file.Name;
Files.Add(file.Index, localFile);
}
await file.Buffer.AppendToFileAsync(Files[file.Index]);
}
private async Task OnCompletedAsync(IEnumerable<FluentInputFileEventArgs> files)
{
progressPercent = myFileByBuffer!.ProgressPercent;
progressTitle = myFileByBuffer!.ProgressTitle;
foreach (var file in Files)
{
var extension = Path.GetExtension(file.Value);
if (extension is ".xml")
{
await ImportXmlAndShowResultAsync(file.Value);
}
else if (extension is ".zip")
{
var unzippedFiles = UnzipAndListFiles(file.Value);
foreach (var unzippedFile in unzippedFiles)
{
await ImportXmlAndShowResultAsync(unzippedFile);
File.Delete(unzippedFile);
}
}
File.Delete(file.Value);
}
Files.Clear();
await OnImported.InvokeAsync();
}
private async Task ImportXmlAndShowResultAsync(string fileName)
{
var messages = await fileService.ImportAccountStatementFileAsync(fileName);
foreach (var message in messages)
{
await ShowImportResultAsync(message);
}
}
private IReadOnlyList<string> UnzipAndListFiles(string zipFilePath)
{
var tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
Directory.CreateDirectory(tempExtractPath);
ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath);
return new List<string>(Directory.GetFiles(tempExtractPath, "*", SearchOption.AllDirectories));
}
private async Task ShowImportResultAsync(MessageDataset message)
{
var joinedDetails = string.Join(", ", message.Lines);
var text = $"{message.Title}: {joinedDetails}";
if (message.Status == MessageStatus.Success)
{
ShowToast(text, ToastIntent.Success);
}
else if (message.Status == MessageStatus.Info)
{
ShowToast(text, ToastIntent.Info);
}
else if (message.Status == MessageStatus.Warning)
{
await ShowMessageAsync(text, MessageIntent.Warning);
}
else if (message.Status == MessageStatus.Error)
{
await ShowMessageAsync(text, MessageIntent.Error);
}
}
private async Task ShowMessageAsync(string message, MessageIntent intent)
{
await messageService.ShowMessageBarAsync(message, intent, "MESSAGES_TOP");
}
private void ShowToast(string message, ToastIntent intent)
{
toastService.ShowToast(intent, message, 3000);
}
}