From 97bf85d6a800b2f47bb08c65e340e04372b4fe98 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Mon, 6 Jul 2026 16:29:51 +0200 Subject: [PATCH] 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 and wiring to it. Also renames IsCanceled -> cancelRequested and clears the Files dictionary after each import batch. Co-Authored-By: Claude Opus 4.7 --- .../Components/Pages/Home.razor | 99 +--------- .../Components/Pages/Home.razor.cs | 180 +----------------- .../Components/Pages/PendingBookings.razor | 73 +++++++ .../Components/Pages/PendingBookings.razor.cs | 81 ++++++++ .../Components/Pages/StatementImport.razor | 26 +++ .../Components/Pages/StatementImport.razor.cs | 128 +++++++++++++ 6 files changed, 315 insertions(+), 272 deletions(-) create mode 100644 Schaad.Accounting.UI/Components/Pages/PendingBookings.razor create mode 100644 Schaad.Accounting.UI/Components/Pages/PendingBookings.razor.cs create mode 100644 Schaad.Accounting.UI/Components/Pages/StatementImport.razor create mode 100644 Schaad.Accounting.UI/Components/Pages/StatementImport.razor.cs diff --git a/Schaad.Accounting.UI/Components/Pages/Home.razor b/Schaad.Accounting.UI/Components/Pages/Home.razor index d526f59..c707555 100644 --- a/Schaad.Accounting.UI/Components/Pages/Home.razor +++ b/Schaad.Accounting.UI/Components/Pages/Home.razor @@ -1,102 +1,9 @@ -@page "/" +@page "/" @rendermode InteractiveServer Home - + - - - @progressTitle - - - - Upload files - - - - Cancel - - -@if (matchingBankTransactions == null || matchingBankTransactions.Count == 0) -{ -

Keine Transaktionen vorhanden!

- -} -else -{ - - - - - - - - - - - - - - - @foreach (var transaction in matchingBankTransactions) - { - - - - - - - - - - - } - -
BuchungsdatumValutadatumZusatzinfoTextKontoBetrag
-
@transaction.BookingDate.ToString("dd.MM.yyyy")
-
-
@transaction.ValueDate.ToString("dd.MM.yyyy")
-
-
@transaction.BankTransactionText
-
-
- - - @foreach (var text in bookingTexts.OrderBy(t => t)) - { - - } - -
-
- @if (transaction.Value > 0) - { - - } - else - { - - } - -
@Math.Abs(transaction.Value).ToFormattedString()
-
- - - -
-} \ No newline at end of file + diff --git a/Schaad.Accounting.UI/Components/Pages/Home.razor.cs b/Schaad.Accounting.UI/Components/Pages/Home.razor.cs index cf77dfb..34a0e34 100644 --- a/Schaad.Accounting.UI/Components/Pages/Home.razor.cs +++ b/Schaad.Accounting.UI/Components/Pages/Home.razor.cs @@ -1,188 +1,16 @@ -using System.IO.Compression; 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; public partial class Home : ComponentBase { - [Inject] - private IViewService viewService { get; set; } = null!; - - [Inject] - private IToastService toastService { get; set; } = null!; - - [Inject] - private IMessageService messageService { get; set; } = null!; - - [Inject] - private IDialogService dialogService { get; set; } = null!; + private PendingBookings? pendingBookings; - [Inject] - private IFileService fileService { get; set; } = null!; - - [Inject] - private IBookingTextRepository bookingTextRepository { get; set; } = null!; - - [Inject] - private ITransactionRepository transactionRepository { get; set; } = null!; - - private List? matchingBankTransactions; - private IReadOnlyList accounts = []; - private IReadOnlyList bookingTexts = []; - - FluentInputFile? myFileByBuffer; - int? progressPercent; - string? progressTitle; - bool IsCanceled; - Dictionary Files = new(); - - protected override async Task OnInitializedAsync() + private async Task RefreshPendingAsync() { - await LoadDataAsync(); - 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)) + if (pendingBookings is not null) { - var localFile = Path.GetTempFileName() + file.Name; - Files.Add(file.Index, localFile); - } - - // Write to the FileStream - await file.Buffer.AppendToFileAsync(Files[file.Index]); - } - - private async Task OnCompletedAsync(IEnumerable 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 UnzipAndListFiles(string zipFilePath) - { - var tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); - Directory.CreateDirectory(tempExtractPath); - ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath); - return new List(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(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(); + await pendingBookings.RefreshAsync(); } } } diff --git a/Schaad.Accounting.UI/Components/Pages/PendingBookings.razor b/Schaad.Accounting.UI/Components/Pages/PendingBookings.razor new file mode 100644 index 0000000..04137a3 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/PendingBookings.razor @@ -0,0 +1,73 @@ +@rendermode InteractiveServer + +@if (matchingBankTransactions is null || matchingBankTransactions.Count == 0) +{ +

Keine Transaktionen vorhanden!

+ +} +else +{ + + + + + + + + + + + + + + + @foreach (var transaction in matchingBankTransactions) + { + + + + + + + + + + + } + +
BuchungsdatumValutadatumZusatzinfoTextKontoBetrag
+
@transaction.BookingDate.ToString("dd.MM.yyyy")
+
+
@transaction.ValueDate.ToString("dd.MM.yyyy")
+
+
@transaction.BankTransactionText
+
+
+ + + @foreach (var text in bookingTexts.OrderBy(t => t)) + { + + } + +
+
+ @if (transaction.Value > 0) + { + + } + else + { + + } + +
@Math.Abs(transaction.Value).ToFormattedString()
+
+ + + +
+} diff --git a/Schaad.Accounting.UI/Components/Pages/PendingBookings.razor.cs b/Schaad.Accounting.UI/Components/Pages/PendingBookings.razor.cs new file mode 100644 index 0000000..3114b57 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/PendingBookings.razor.cs @@ -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? matchingBankTransactions; + private IReadOnlyList accounts = []; + private IReadOnlyList bookingTexts = []; + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + await base.OnInitializedAsync(); + } + + /// + /// Re-run the auto-matcher and reload accounts / booking texts. Called from + /// the parent (Home) after a statement import completes. + /// + 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(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(); + } + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/StatementImport.razor b/Schaad.Accounting.UI/Components/Pages/StatementImport.razor new file mode 100644 index 0000000..1e705cf --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/StatementImport.razor @@ -0,0 +1,26 @@ +@rendermode InteractiveServer + + + + + + @progressTitle + + + + Upload files + + + + Cancel + diff --git a/Schaad.Accounting.UI/Components/Pages/StatementImport.razor.cs b/Schaad.Accounting.UI/Components/Pages/StatementImport.razor.cs new file mode 100644 index 0000000..f4c8e8d --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/StatementImport.razor.cs @@ -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!; + + /// + /// Fires after all uploaded files have been processed, so the parent can + /// refresh dependent views (e.g. re-run the auto-matcher on Home). + /// + [Parameter] + public EventCallback OnImported { get; set; } + + private FluentInputFile? myFileByBuffer; + private int? progressPercent; + private string? progressTitle; + private bool cancelRequested; + private Dictionary 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 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 UnzipAndListFiles(string zipFilePath) + { + var tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + Directory.CreateDirectory(tempExtractPath); + ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath); + return new List(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); + } +}