feat(accounts): preview and confirm cross-year balance corrections (U12)

This commit is contained in:
2026-11-02 19:28:30 +01:00
parent 4d0d5c8d88
commit 45fe327840
13 changed files with 158 additions and 57 deletions
@@ -1,4 +1,5 @@
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name))">
@inject ToastService Toasts
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name) || initialAmount != "0,00")" Busy="saving">
<h5 class="mb-3">Neues Konto</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true">
<div class="form-container">
@@ -6,8 +7,11 @@
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
placeholder="z.B. Girokonto" />
</div>
<label for="account-initial">Anfangsbestand (€)</label>
<AmountInput Id="account-initial" @bind-Value="initialAmount" AllowNegative="true" Disabled="saving" />
<p>Geld, das vor der ersten erfassten Buchung vorhanden ist. 0 ist möglich, ein Minus bei überzogenem Konto ebenfalls. Der Anfangsbestand zählt nicht als neue Einnahme.</p>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
<button type="submit" class="btn btn-success" disabled="@(saving || string.IsNullOrWhiteSpace(name) || !ValidInitial)"><i class="bi bi-check-lg"></i> Erstellen</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
@@ -7,7 +7,7 @@ public partial class AddAccountDialog
#region Parameters
[Parameter]
public EventCallback<string> OnSave { get; set; }
public EventCallback<Duempelkas.App.Services.Models.AccountSetup> OnSave { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
@@ -17,6 +17,9 @@ public partial class AddAccountDialog
#region Fields
private string name = string.Empty;
private string initialAmount = "0,00";
private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _);
private bool saving;
#endregion
@@ -24,9 +27,12 @@ public partial class AddAccountDialog
private async Task Save()
{
if (!string.IsNullOrWhiteSpace(name))
if (!saving && !string.IsNullOrWhiteSpace(name) && ValidInitial)
{
await OnSave.InvokeAsync(name.Trim());
saving = true;
try { Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out var initial); await OnSave.InvokeAsync(new(name.Trim(), initial)); }
catch (Exception ex) { Toasts.Show("Das Konto konnte nicht angelegt werden. Die Eingaben bleiben erhalten.", Duempelkas.App.Services.ToastKind.Error, ex.Message); }
finally { saving = false; }
}
}
@@ -1,13 +1,21 @@
<ModalDialog @ref="modal" Title="Bestand korrigieren" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)">
<h5 class="mb-3">Übertrag bearbeiten</h5>
<div class="form-container">
<label class="form-label" for="carryover">Übertrag (€)</label>
<input id="carryover" data-initial-focus type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
@inject ToastService Toasts
<ModalDialog @ref="modal" Title="Bestandskorrektur" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)" Busy="saving">
<h5>Bestandskorrektur – @AccountName</h5>
<p>@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")</p>
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
<p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
<label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
<AmountInput Id="carryover" Value="amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
<div class="correction-preview">
<p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
<p>Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)</p>
<p>Kontostand der Ansicht: @Money(CurrentEndBalance) → @Money(CurrentEndBalance + Delta)</p>
<p><strong>Alle Kontostände dieses Kontos ändern sich um @Money(Delta). Das betrifft frühere und spätere Jahre.</strong></p>
</div>
<label><input type="checkbox" @bind="acknowledged" disabled="@saving" /> Ich habe die Auswirkungen auf alle Jahre geprüft.</label>
<div class="dialog-actions">
<button type="button" class="btn btn-success" disabled="@(saving || !ValidAmount || !acknowledged || Delta == 0m)" @onclick="Save">Korrektur speichern</button>
<button type="button" class="btn btn-outline-secondary" disabled="@saving" data-safe-focus @onclick="Cancel">Abbrechen</button>
</div>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -1,39 +1,36 @@
using Microsoft.AspNetCore.Components;
using Duempelkas.App.Services;
namespace Duempelkas.App.Components.Dialogs;
public partial class EditCarryoverDialog
{
#region Parameters
[Parameter]
public decimal CurrentAmount { get; set; }
[Parameter]
public EventCallback<decimal> OnSave { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
#endregion
#region Fields
private decimal amount;
#endregion
#region Lifecycle
protected override void OnParametersSet() => amount = CurrentAmount;
#endregion
#region Actions
private async Task Save() => await OnSave.InvokeAsync(amount);
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion
[Parameter] public decimal CurrentAmount { get; set; }
[Parameter] public decimal InitialBalance { get; set; }
[Parameter] public decimal CurrentEndBalance { get; set; }
[Parameter] public int? Year { get; set; }
[Parameter] public string AccountName { get; set; } = "Konto";
[Parameter] public EventCallback<decimal> OnSave { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
private string amountText = string.Empty;
private bool acknowledged;
private bool initialized;
private bool saving;
private bool ValidAmount => BookingInput.TryBalance(amountText, out _);
private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount;
private decimal Delta => amount - CurrentAmount;
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
protected override void OnParametersSet()
{
if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; }
}
private void ChangeAmount(string value) { amountText = value; acknowledged = false; }
private async Task Save()
{
if (saving || !ValidAmount || !acknowledged || Delta == 0m) return;
saving = true;
try { await OnSave.InvokeAsync(amount); }
catch (Exception ex) { Toasts.Show("Die Korrektur konnte nicht gespeichert werden. Bitte den aktuellen Bestand prüfen.", ToastKind.Error, ex.Message); }
finally { saving = false; }
}
private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask;
}
@@ -125,7 +125,7 @@
@if (showEditCarryover)
{
<EditCarryoverDialog CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
<EditCarryoverDialog AccountName="@(account?.Name ?? "")" Year="selectedYear" InitialBalance="@(account?.CarryoverBalance ?? 0)" CurrentEndBalance="@(balance?.TotalBalance ?? 0)" CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
OnSave="HandleSaveCarryover"
OnCancel="() => showEditCarryover = false" />
}
@@ -110,9 +110,10 @@ public partial class AccountDetail
private async Task HandleSaveCarryover(decimal newAmount)
{
await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear);
showEditCarryover = false;
await AccountService.CorrectCarryoverAsync(AccountId, newAmount, selectedYear, balance!.CarryoverBalance, account!.CarryoverBalance);
await LoadAll();
showEditCarryover = false;
Toasts.Show("Bestandskorrektur gespeichert. Die Änderung gilt für alle Jahre.", ToastKind.Success);
}
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
+2 -2
View File
@@ -84,9 +84,9 @@ public partial class Dashboard
#region Actions
private async Task HandleAccountCreated(string name)
private async Task HandleAccountCreated(AccountSetup setup)
{
await AccountService.CreateAccountAsync(name);
await AccountService.CreateAccountAsync(setup.Name, setup.InitialBalance);
showAddAccount = false;
await LoadAll();
}
@@ -20,6 +20,14 @@ public static partial class BookingInput
&& amount > 0;
}
public static bool TryBalance(string text, out decimal amount)
{
amount = 0;
var plain = text.Trim();
return Regex.IsMatch(plain, @"^-?\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)
&& decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out amount);
}
public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
@@ -8,8 +8,10 @@ public interface IAccountService
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
Task<AccountSummaryDto> GetAccountAsync(int accountId);
Task<AccountSummaryDto> CreateAccountAsync(string name);
Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance);
Task RenameAccountAsync(int accountId, string newName);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial);
Task DeleteAccountAsync(int accountId);
}
@@ -0,0 +1,3 @@
namespace Duempelkas.App.Services.Models;
public sealed record AccountSetup(string Name, decimal InitialBalance);