Add transaction split dialog

This commit is contained in:
Claudio Schaad 2025-05-13 20:27:09 +02:00
parent e76644a122
commit c86edfdde2
14 changed files with 276 additions and 33 deletions

View file

@ -3,7 +3,7 @@ using Schaad.Accounting.Models;
namespace Schaad.Accounting.Datasets namespace Schaad.Accounting.Datasets
{ {
public record TransactionDataset : Transaction public class TransactionDataset : Transaction
{ {
public Account OriginAccount { get; set; } public Account OriginAccount { get; set; }

View file

@ -5,7 +5,7 @@ using System.Linq;
namespace Schaad.Accounting.Models namespace Schaad.Accounting.Models
{ {
public record Transaction public class Transaction
{ {
public string Id { get; set; } public string Id { get; set; }
public string BankTransactionId { get; set; } public string BankTransactionId { get; set; }

View file

@ -30,6 +30,8 @@ namespace Schaad.Accounting.Repositories
/// </summary> /// </summary>
public void SaveTransaction(Transaction transaction) public void SaveTransaction(Transaction transaction)
{ {
transaction.Value = Math.Abs(transaction.Value);
// prevent saving fxrate for chf account // prevent saving fxrate for chf account
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount; var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount;

View file

@ -169,7 +169,7 @@ namespace Schaad.Accounting.Services
if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId ) if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId )
{ {
return t with { Value = -t.Value }; t.Value *= -1;
} }
return t; return t;

View file

@ -0,0 +1,7 @@
<select @bind="@AccountId" @oninput="OnInputChanged">
<option value=""></option>
@foreach (var account in Accounts.OrderBy(a => a.Number).ThenBy(a => a.Name))
{
<option value="@account.Id">@($"{account.Number} / {account.Name}")</option>
}
</select>

View file

@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Components;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.UI.Components.Pages.Controls;
public partial class AccountSelector : ComponentBase
{
[Parameter] public required string? AccountId { get; set; }
[Parameter] public required IReadOnlyList<Account> Accounts { get; set; }
[Parameter] public EventCallback<string> AccountIdChanged { get; set; }
private async Task OnInputChanged(ChangeEventArgs e)
{
AccountId = e.Value?.ToString();
await AccountIdChanged.InvokeAsync(AccountId);
}
}

View file

@ -0,0 +1,5 @@
select {
width: 100%;
height: 30px;
box-sizing: border-box;
}

View file

@ -0,0 +1,83 @@
@implements IDialogContentComponent<Transaction>
@rendermode InteractiveServer
<FluentDialogHeader ShowDismiss="true">
<FluentStack VerticalAlignment="VerticalAlignment.Center">
<FluentIcon Value="@(new Icons.Regular.Size24.WindowApps())"/>
<FluentLabel Typo="Typography.PaneHeader">
@Dialog.Instance.Parameters.Title
</FluentLabel>
</FluentStack>
</FluentDialogHeader>
<FluentDialogBody>
Total: @Math.Abs(Content.Value) / Rest: @openAmount
<FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())"
OnClick="@AddTransactionAsync">
Transaktion hinzufügen
</FluentButton>
<EditForm EditContext="@editContext">
<DataAnnotationsValidator/>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Betrag</th>
<th>Text</th>
<th>Konto</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in transactionList)
{
<tr>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>
<InputNumber @bind-Value="@transaction.Value" @onblur="CalculateOpenAmount"/>
</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..."/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
<div class="account-column">
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
</div>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral"
OnClick="@(() => transactionList.Remove(transaction))"/>
</td>
</tr>
}
</tbody>
</table>
<div>
<FluentValidationSummary/>
</div>
</EditForm>
</FluentDialogBody>
<FluentDialogFooter>
<FluentButton Appearance="Appearance.Neutral"
OnClick="@CancelAsync">
Abbrechen
</FluentButton>
<FluentButton Appearance="Appearance.Accent" Disabled="@(openAmount != 0)"
OnClick="@SaveAsync">
Speichern
</FluentButton>
</FluentDialogFooter>

View file

@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class TransactionSplitDialog : ComponentBase
{
private EditContext editContext = null!;
[CascadingParameter]
public FluentDialog Dialog { get; set; } = null!;
[Parameter]
public Transaction Content { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
[Inject]
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
[Inject]
private IAccountRepository accountRepository { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private IViewService viewService { get; set; } = null!;
private List<Transaction> transactionList = new();
private IReadOnlyList<AccountDataset> accounts = null!;
private IReadOnlyList<string> bookingTexts = [];
private decimal openAmount = 0;
protected override void OnInitialized()
{
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
var accountList = accountRepository.GetAccountList();
var bankTrx = bankTransactionRepository.GetBankTransaction(Content.BankTransactionId);
var trx = new Transaction(bankTrx, accountList);
trx.Value = Math.Abs(bankTrx.Value);
transactionList.Add(trx);
editContext = new EditContext(trx);
}
private async Task AddTransactionAsync()
{
transactionList.Add(new Transaction
{
ValueDate = Content.ValueDate,
BookingDate = Content.BookingDate,
OriginAccountId = Content.OriginAccountId,
Value = 0
});
await Task.CompletedTask;
}
private void CalculateOpenAmount()
{
openAmount = Content.Value + transactionList.Sum(t => t.Value);
}
private async Task SaveAsync()
{
if (editContext.Validate())
{
foreach (var transtaction in transactionList)
{
transactionRepository.SaveTransaction(transtaction);
}
await Dialog.CloseAsync(Content);
}
}
private async Task CancelAsync()
{
await Dialog.CancelAsync();
}
}

View file

@ -0,0 +1,10 @@
th:nth-child(1), td:nth-child(1) { width: 100px; }
th:nth-child(2), td:nth-child(2) { width: 50px; }
th:nth-child(3), td:nth-child(3) { width: 200px; }
th:nth-child(4), td:nth-child(4) { width: 200px; }
th:nth-child(5), td:nth-child(5) { width: 40px; text-align: center; }
.text-column input {
width: 100%;
box-sizing: border-box;
}

View file

@ -12,18 +12,20 @@
MaximumFileSize="@(10 * 1024 * 1024)" MaximumFileSize="@(10 * 1024 * 1024)"
Accept=".zip, .xml" Accept=".zip, .xml"
OnProgressChange="@OnProgressChangeAsync" OnProgressChange="@OnProgressChangeAsync"
OnCompleted="@OnCompletedAsync" /> OnCompleted="@OnCompletedAsync"/>
<FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent" /> <FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent"/>
<FluentLabel Alignment="HorizontalAlignment.Center"> <FluentLabel Alignment="HorizontalAlignment.Center">
@progressTitle @progressTitle
</FluentLabel> </FluentLabel>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.ArrowUpload())" Id="MyUploadBuffer"> <FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.ArrowUpload())"
Id="MyUploadBuffer">
Upload files Upload files
</FluentButton> </FluentButton>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.Dismiss())" @onclick="@((e) => IsCanceled = true)"> <FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.Dismiss())"
@onclick="@((e) => IsCanceled = true)">
Cancel Cancel
</FluentButton> </FluentButton>
@ -38,10 +40,10 @@ else
<tr> <tr>
<th>Buchungsdatum</th> <th>Buchungsdatum</th>
<th>Valutadatum</th> <th>Valutadatum</th>
<th>Text</th>
<th>Betrag</th> <th>Betrag</th>
<th>Konto</th>
<th>Zusatzinfo</th> <th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>
@ -51,9 +53,17 @@ else
{ {
<tr> <tr>
<td> <td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div></td> <div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td> <td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div></td> <div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="betrag-column">
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td> <td>
<div class="text-column"> <div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..."/> <input list="texts" @bind="@transaction.Text" placeholder="Type or select..."/>
@ -65,23 +75,24 @@ else
</datalist> </datalist>
</div> </div>
</td> </td>
<td class="betrag-column">
<div>@transaction.Value.ToFormattedString()</div>
</td>
<td> <td>
<select @bind="@transaction.TargetAccountId"> @if (transaction.Value > 0)
<option value=""></option> {
@foreach (var account in accounts.OrderBy(a => a.Number).ThenBy(a => a.Name)) <AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
{ }
<option value="@account.Id">@($"{account.Number} / {@account.Name}")</option> else
} {
</select> <AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td> </td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText"> <td style="align-content: center">
<div>@transaction.BankTransactionText</div> <FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0)"/>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
OnClick="@(() => BookBankTransactionAsync(transaction))"/>
</td> </td>
<td style="align-content: center"><FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral" OnClick="@(() => SplitBankTransactionAsync(transaction))"/></td>
<td style="align-content: center"><FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral" OnClick="@(() => BookBankTransactionAsync(transaction))"/></td>
</tr> </tr>
} }
</tbody> </tbody>

View file

@ -5,6 +5,7 @@ using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages; namespace Schaad.Accounting.UI.Components.Pages;
@ -19,6 +20,9 @@ public partial class Home : ComponentBase
[Inject] [Inject]
private IMessageService messageService { get; set; } = null!; private IMessageService messageService { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject] [Inject]
private ISettingsService settingsService { get; set; } = null!; private ISettingsService settingsService { get; set; } = null!;
@ -166,7 +170,20 @@ public partial class Home : ComponentBase
private async Task SplitBankTransactionAsync(Transaction transaction) private async Task SplitBankTransactionAsync(Transaction transaction)
{ {
ShowToast("Split noch nicht implementiert!", ToastIntent.Warning); var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
await Task.CompletedTask; {
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
matchingBankTransactions!.RemoveAll(q => q == transaction);
await Task.CompletedTask;
}
} }
} }

View file

@ -16,16 +16,17 @@ th {
background-color: #f8f8f8; background-color: #f8f8f8;
} }
th:nth-child(1), td:nth-child(1) { width: 120px; } /* Buchungsdatum */ th:nth-child(1), td:nth-child(1) { width: 120px; }
th:nth-child(2), td:nth-child(2) { width: 120px; } /* Valutadatum */ th:nth-child(2), td:nth-child(2) { width: 120px; }
th:nth-child(3), td:nth-child(3) { width: 240px; } /* Text */ th:nth-child(3), td:nth-child(3) { width: 100px; text-align: right; }
th:nth-child(4), td:nth-child(4) { width: 100px; text-align: right; } /* Betrag */ th:nth-child(5), td:nth-child(5) { width: 240px; }
th:nth-child(5), td:nth-child(5) { width: 300px; } /* Konto */ th:nth-child(6), td:nth-child(6) { width: 300px; }
th:nth-child(7), td:nth-child(7), th:nth-child(7), td:nth-child(7),
th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; } /* Buttons */ th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; }
.text-column input { .text-column input {
width: 100%; width: 100%;
height: 30px;
box-sizing: border-box; /* Ensures padding/border doesn't overflow */ box-sizing: border-box; /* Ensures padding/border doesn't overflow */
} }

View file

@ -13,6 +13,7 @@
@using Plotly.Blazor @using Plotly.Blazor
@using Schaad.Accounting.UI @using Schaad.Accounting.UI
@using Schaad.Accounting.UI.Components @using Schaad.Accounting.UI.Components
@using Schaad.Accounting.UI.Components.Pages.Controls
@using Schaad.Accounting.Models @using Schaad.Accounting.Models
@using Schaad.Accounting.Interfaces @using Schaad.Accounting.Interfaces