Compare commits

...
5 Commits
38 changed files with 634 additions and 139 deletions
+20
View File
@@ -59,3 +59,23 @@ Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separ
## Weiterer Umsetzungsplan ## Weiterer Umsetzungsplan
U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische Kontolinks. U09: lesbare Zeilenaktionen mit größeren Treffern. U10: Löschstatus, Bestätigung beider Umbuchungsseiten und gezieltes Rückgängig. U11: Richtung und Auswirkungen von beiden Seiten identisch erklären. U12: bestehende gemeinsame Anfangsbestands-Semantik beibehalten und Korrektur vorab erklären/bestätigen. Danach U13: skalierbare lesbare Darstellung/Kontrast; U14: lokale Hilfe und überspringbare Einführung; U15: Suche und Sortierung mit getrennten Treffermengen; U16: lokale Darstellungsressourcen und sichere Fehlerauswege. Nach jeder Maßnahme bauen und testen; Paketgrenzen pushen und PR aktualisieren. U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische Kontolinks. U09: lesbare Zeilenaktionen mit größeren Treffern. U10: Löschstatus, Bestätigung beider Umbuchungsseiten und gezieltes Rückgängig. U11: Richtung und Auswirkungen von beiden Seiten identisch erklären. U12: bestehende gemeinsame Anfangsbestands-Semantik beibehalten und Korrektur vorab erklären/bestätigen. Danach U13: skalierbare lesbare Darstellung/Kontrast; U14: lokale Hilfe und überspringbare Einführung; U15: Suche und Sortierung mit getrennten Treffermengen; U16: lokale Darstellungsressourcen und sichere Fehlerauswege. Nach jeder Maßnahme bauen und testen; Paketgrenzen pushen und PR aktualisieren.
## U08 – Tastatur und Modaldialoge
Gemeinsame native dialog-Komponente für Erfassung, Bestätigungen, Konten, Namen, Übertrag, Jahr und Über. showModal sperrt den Hintergrund und hält den Tastaturfokus im Dialog; Fokus geht ins sinnvolle Feld und nach Schließen zum Auslöser zurück. Escape schließt unveränderte Dialoge; geänderte Eingaben bieten Weiterbearbeiten/Verwerfen. Während Speichern kein Schließen. Unsichere Bestätigungen fokussieren Abbrechen und verwenden keine implizite Formularbestätigung. Kontokarten sind semantische Links. Labels sind technisch verbunden. Toasts bleiben auch innerhalb nativer Modaldialoge zugänglich. Prüfung: 44/44 Debug-Tests, warnungsfreier Build; Computer Use beobachtete Fokus im Beschreibungsfeld, ausschließlich modalen Accessibility-Baum und Escape-Schließen. Kein vollständiger Screenreader-Nutzertest.
## U09 – Zeilenaktionen
Beschriftetes Aktionsmenü je Buchung, sichtbares Bearbeiten/Löschen und Wiederherstellen als Wort. Zugängliche Namen nennen Nummer und Beschreibung, Trefferbereiche mindestens 44 CSS-Pixel. Auf schmalen Fenstern wechseln Zeilen in beschriftete Karten, damit die Aktionsspalte nicht die Beschreibung verdrängt. Gerenderter Test prüft Zuordnung und Aktion.
## U10 – Löschen und Rückweg
Gelöschte Zeilen sind standardmäßig ausgeblendet und gezählt erreichbar; sichtbare Historie erklärt die Nichtberücksichtigung im Kontostand. Bestätigungen nennen Datum, Beschreibung, Betrag, Konto und bei Umbuchungen beide Konten mit gemeinsamer Wirkung. Erfolg bietet gezieltes Rückgängig; Mehrfachauslösung wird gesperrt, bereits veränderter Zustand geprüft. Gerenderter Integrationstest löscht eine Umbuchung, prüft Gegenkonto, Historie und Rückgängig auf beiden Seiten.
## U11 – Umbuchungsrichtung
Beide Seiten zeigen dieselbe Richtung Von → Nach und erklären das feste aktuelle sowie änderbare Gegenkonto. Vorschau zeigt Minus/Plus und unveränderte Vereinssumme; nur ein Konto erklärt die fehlende Voraussetzung. Der Dienst weist beim Bearbeiten gleiche Konten und fehlende Gegenkonten vor jeder Änderung ab. Tests öffnen beide Seiten, ändern die Quelle einer empfangenen Umbuchung und prüfen den unveränderten Bestand nach unzulässiger Änderung.
## U12 – Anfangsbestand und Bestandskorrektur
Neue Konten erhalten einen erklärten Anfangsbestand ohne künstliche Einnahme. Spätere Korrektur zeigt Konto/Jahr, Formel, vorher/nachher für gewählten Bestand, gemeinsamen Anfangsbestand und Endstand sowie ausdrücklich die Auswirkungen auf frühere/spätere Jahre. Zustimmung ist erst nach Eingabe gültig, Abbrechen hat Anfangsfokus. Bestehende gemeinsame Semantik bleibt unverändert, keine Migration oder jahresbezogene neue Speicherregel. Dienst prüft den erwarteten Bestand in einer Transaktion und weist veraltete Vorschauen ab. Mehrjahrestest prüft die Verschiebung aller Jahre bei unveränderten Buchungen, Formularprüfung die notwendige Zustimmung.
@@ -1,11 +1,11 @@
@inject NavigationManager Navigation @inject NavigationManager Navigation
<div class="card account-card h-100" @onclick="Navigate"> <a class="card account-card h-100" href="@($"/accounts/{Account.Id}")" @onclick="Navigate" @onclick:preventDefault="true">
<div class="card-body d-flex flex-column justify-content-between"> <div class="card-body d-flex flex-column justify-content-between">
<h3 class="card-title mb-3 mt-0">@Account.Name</h3> <h3 class="card-title mb-3 mt-0">@Account.Name</h3>
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4"> <div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
@($"{Account.TotalBalance:N2} €") @($"{Account.TotalBalance:N2} €")
</div> </div>
</div> </div>
</div> </a>
@@ -1,8 +1,12 @@
<tr class="@GetRowClass()"> <tr class="@GetRowClass()">
<td>@Entry.DisplayId</td> <td data-label="Nummer">@Entry.DisplayId</td>
<td>@Entry.Date.ToString("dd.MM.yyyy")</td> <td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
<td> <td data-label="Beschreibung" class="entry-description">
@Entry.Title @Entry.Title
@if (Entry.IsDeleted)
{
<p class="deleted-status">Gelöscht – zählt nicht zum Kontostand</p>
}
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName)) @if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
{ {
<small class="text-muted ms-1"> <small class="text-muted ms-1">
@@ -10,26 +14,27 @@
</small> </small>
} }
</td> </td>
<td class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")"> <td data-label="Betrag" class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
@(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €") @(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €")
</td> </td>
<td> <td data-label="Aktionen" class="entry-actions">
@if (Entry.IsDeleted) @if (Entry.IsDeleted)
{ {
<button class="btn btn-outline-success btn-sm" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" title="Wiederherstellen"> <button type="button" class="btn btn-outline-success" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} wiederherstellen")">
<i class="bi bi-arrow-counterclockwise"></i> <i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Wiederherstellen
</button> </button>
} }
else else
{ {
<div class="d-flex gap-1"> <details class="entry-action-menu">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" title="Bearbeiten"> <summary aria-label="@($"Aktionen für {Entry.DisplayId} {Entry.Title}")">Aktionen</summary>
<i class="bi bi-pencil"></i> <button type="button" class="btn btn-outline-secondary" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} bearbeiten")">
<i class="bi bi-pencil" aria-hidden="true"></i> Bearbeiten
</button> </button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" title="Löschen"> <button type="button" class="btn btn-outline-danger" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} löschen")">
<i class="bi bi-trash"></i> <i class="bi bi-trash" aria-hidden="true"></i> Löschen
</button> </button>
</div> </details>
} }
</td> </td>
</tr> </tr>
@@ -1,4 +1,8 @@
<div class="table-responsive"> @if (Entries.Any(e => e.IsDeleted))
{
<label class="history-toggle"><input type="checkbox" @bind="showDeleted" /> Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))</label>
}
<div class="table-responsive entry-list">
<table class="table table-hover table-sm align-middle"> <table class="table table-hover table-sm align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@@ -6,11 +10,11 @@
<th style="width: 120px;">Datum</th> <th style="width: 120px;">Datum</th>
<th>Bezeichnung</th> <th>Bezeichnung</th>
<th style="width: 140px;" class="text-end">Betrag</th> <th style="width: 140px;" class="text-end">Betrag</th>
<th style="width: 80px;"></th> <th style="width: 11rem;">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var entry in Entries) @foreach (var entry in VisibleEntries)
{ {
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" /> <EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
} }
@@ -18,7 +22,7 @@
</table> </table>
</div> </div>
@if (!Entries.Any()) @if (!VisibleEntries.Any())
{ {
<div class="text-center py-3 text-muted">@EmptyMessage</div> <div class="text-center py-3 text-muted">@EmptyMessage</div>
} }
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryTable public partial class EntryTable
{ {
private bool showDeleted;
private IEnumerable<EntryDto> VisibleEntries => Entries.Where(e => !e.IsDeleted || showDeleted);
[Parameter] public int? HighlightedEntryId { get; set; } [Parameter] public int? HighlightedEntryId { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden."; [Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
#region Parameters #region Parameters
@@ -1,15 +1,20 @@
<div class="dialog-backdrop"> @inject ToastService Toasts
<div class="dialog-content" @onclick:stopPropagation="true"> <ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name) || initialAmount != "0,00")" Busy="saving">
<h5 class="mb-3">Neues Konto</h5> <h5 class="mb-3">Neues Konto</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true">
<div class="form-container"> <div class="form-container">
<label class="form-label">Kontoname</label> <label class="form-label" for="account-name">Kontoname</label>
<input type="text" class="form-control" @bind="name" @bind:event="oninput" <input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
placeholder="z.B. Girokonto" /> placeholder="z.B. Girokonto" />
</div> </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"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" 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 class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</div> </form>
</div> </ModalDialog>
@code { private ModalDialog? modal; }
@@ -7,7 +7,7 @@ public partial class AddAccountDialog
#region Parameters #region Parameters
[Parameter] [Parameter]
public EventCallback<string> OnSave { get; set; } public EventCallback<Duempelkas.App.Services.Models.AccountSetup> OnSave { get; set; }
[Parameter] [Parameter]
public EventCallback OnCancel { get; set; } public EventCallback OnCancel { get; set; }
@@ -17,6 +17,9 @@ public partial class AddAccountDialog
#region Fields #region Fields
private string name = string.Empty; private string name = string.Empty;
private string initialAmount = "0,00";
private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _);
private bool saving;
#endregion #endregion
@@ -24,13 +27,16 @@ public partial class AddAccountDialog
private async Task Save() 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; }
} }
} }
private async Task Cancel() => await OnCancel.InvokeAsync(); private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion #endregion
} }
@@ -1,8 +1,7 @@
@inject ToastService Toasts @inject ToastService Toasts
@inject IEntryService EntryService @inject IEntryService EntryService
<div class="dialog-backdrop"> <ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5> <h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")"> <form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
@@ -31,7 +30,7 @@
<label class="form-label" for="entry-title">Beschreibung</label> <label class="form-label" for="entry-title">Beschreibung</label>
<div> <div>
<input id="entry-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="z. B. Mitgliedsbeitrag oder Porto" aria-describedby="entry-title-help" aria-invalid="@(string.IsNullOrWhiteSpace(title) ? "true" : "false")" /> <input id="entry-title" data-initial-focus type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="z. B. Mitgliedsbeitrag oder Porto" aria-describedby="entry-title-help" aria-invalid="@(string.IsNullOrWhiteSpace(title) ? "true" : "false")" />
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> } @if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
</div> </div>
@@ -58,5 +57,6 @@
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</form> </form>
</div> </ModalDialog>
</div>
@code { private ModalDialog? modal; }
@@ -45,6 +45,9 @@ public partial class AddEntryDialog
private string amountText = string.Empty; private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _); private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0; private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private string initialState = string.Empty;
private string CurrentState => $"{dateText}|{title}|{amountText}|{entryType}";
private bool IsDirty => CurrentState != initialState;
private bool initialized; private bool initialized;
private int? initializedEntryId; private int? initializedEntryId;
@@ -64,6 +67,7 @@ public partial class AddEntryDialog
title = EditEntry.Title; title = EditEntry.Title;
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
} }
initialState = CurrentState;
} }
#endregion #endregion
@@ -106,7 +110,7 @@ public partial class AddEntryDialog
private async Task Cancel() private async Task Cancel()
{ {
if (!isSaving) await OnCancel.InvokeAsync(); if (!isSaving && modal != null) await modal.RequestCloseAsync();
} }
#endregion #endregion
@@ -2,16 +2,25 @@
@inject IEntryService EntryService @inject IEntryService EntryService
@inject IAccountService AccountService @inject IAccountService AccountService
<div class="dialog-backdrop"> <ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
<div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5> <h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p> <p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
<p id="transfer-direction"><strong>Von: @FromName → Nach: @ToName</strong></p>
<p>@CurrentAccountName bleibt fest. @(IsIncoming ? "Das abgebende Konto ist änderbar." : "Das empfangende Konto ist änderbar.")</p>
@if (accounts.Count < 2)
{
<p role="status">Für eine Umbuchung brauchst du ein zweites Konto. Lege es in der Kontenübersicht an.</p>
}
@if (ValidAmount && targetAccountId > 0)
{
<p role="status">@FromName −@Money(amount), @ToName +@Money(amount); Gesamtsumme unverändert.</p>
}
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")"> <form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
<fieldset disabled="@(isSaving || committedResult != null || completed)"> <fieldset disabled="@(isSaving || committedResult != null || completed)">
<div class="form-container"> <div class="form-container">
<label class="form-label">Zielkonto</label> <label class="form-label" for="transfer-account">@(IsIncoming ? "Von Konto (änderbar)" : "Nach Konto (änderbar)")</label>
<select id="transfer-account" class="form-select" @bind="targetAccountId"> <select id="transfer-account" class="form-select" @bind="targetAccountId">
<option value="0">— Auswählen —</option> <option value="0">— Auswählen —</option>
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId)) @foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
@@ -20,13 +29,13 @@
} }
</select> </select>
<label class="form-label">Datum</label> <label class="form-label" for="transfer-date">Datum</label>
<input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" /> <input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
<label class="form-label">Bezeichnung</label> <label class="form-label" for="transfer-title">Bezeichnung</label>
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" /> <input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
<label class="form-label">Betrag (€)</label> <label class="form-label" for="transfer-amount">Betrag (€)</label>
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" /> <div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
<p class="text-muted">Betrag ohne Minuszeichen.</p></div> <p class="text-muted">Betrag ohne Minuszeichen.</p></div>
</div> </div>
@@ -47,6 +56,6 @@
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</form> </form>
</div> </ModalDialog>
</div>
@code { private ModalDialog? modal; }
@@ -42,6 +42,9 @@ public partial class AddTransferDialog
private string amountText = string.Empty; private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _); private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0; private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private string initialState = string.Empty;
private string CurrentState => $"{dateText}|{title}|{amountText}|{targetAccountId}";
private bool IsDirty => CurrentState != initialState;
private bool initialized; private bool initialized;
private int? initializedEntryId; private int? initializedEntryId;
@@ -49,6 +52,13 @@ public partial class AddTransferDialog
#region Properties #region Properties
private bool IsIncoming => EditEntry?.Type == Duempelkas.Domain.Enums.EntryType.Income;
private string CurrentAccountName => accounts.FirstOrDefault(a => a.Id == SourceAccountId)?.Name ?? "Aktuelles Konto";
private string LinkedAccountName => accounts.FirstOrDefault(a => a.Id == targetAccountId)?.Name ?? "Konto auswählen";
private string FromName => IsIncoming ? LinkedAccountName : CurrentAccountName;
private string ToName => IsIncoming ? CurrentAccountName : LinkedAccountName;
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year)); private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
#endregion #endregion
@@ -72,6 +82,7 @@ public partial class AddTransferDialog
title = EditEntry.Title; title = EditEntry.Title;
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
} }
initialState = CurrentState;
} }
#endregion #endregion
@@ -114,7 +125,7 @@ public partial class AddTransferDialog
private async Task Cancel() private async Task Cancel()
{ {
if (!isSaving) await OnCancel.InvokeAsync(); if (!isSaving && modal != null) await modal.RequestCloseAsync();
} }
#endregion #endregion
@@ -1,11 +1,10 @@
<div class="dialog-backdrop"> <ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" Busy="Busy">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5>@Title</h5> <h5>@Title</h5>
<p>@Message</p> <p>@Message</p>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button> <button type="button" disabled="@Busy" class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button> <button type="button" disabled="@Busy" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
</div> </div>
</div> </ModalDialog>
</div>
@code { private ModalDialog? modal; }
@@ -4,6 +4,7 @@ namespace Duempelkas.App.Components.Dialogs;
public partial class ConfirmDialog public partial class ConfirmDialog
{ {
[Parameter] public bool Busy { get; set; }
#region Parameters #region Parameters
[Parameter] [Parameter]
@@ -34,9 +35,9 @@ public partial class ConfirmDialog
#region Actions #region Actions
private async Task Confirm() => await OnConfirm.InvokeAsync(); private async Task Confirm() { if (!Busy) await OnConfirm.InvokeAsync(); }
private async Task Cancel() => await OnCancel.InvokeAsync(); private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion #endregion
} }
@@ -1,14 +1,21 @@
<div class="dialog-backdrop"> @inject ToastService Toasts
<div class="dialog-content" @onclick:stopPropagation="true"> <ModalDialog @ref="modal" Title="Bestandskorrektur" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)" Busy="saving">
<h5 class="mb-3">Übertrag bearbeiten</h5> <h5>Bestandskorrektur – @AccountName</h5>
<div class="form-container"> <p>@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")</p>
<label class="form-label">Übertrag (€)</label> <p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
<input type="number" class="form-control no-spinner" step="0.01" @bind="amount" /> <p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
</div> <label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
<div class="d-flex justify-content-end gap-2"> <AmountInput Id="carryover" Value="amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
<button class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button> <div class="correction-preview">
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
</div> <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> </div>
</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 Microsoft.AspNetCore.Components;
using Duempelkas.App.Services;
namespace Duempelkas.App.Components.Dialogs; namespace Duempelkas.App.Components.Dialogs;
public partial class EditCarryoverDialog public partial class EditCarryoverDialog
{ {
#region Parameters [Parameter] public decimal CurrentAmount { get; set; }
[Parameter] public decimal InitialBalance { get; set; }
[Parameter] [Parameter] public decimal CurrentEndBalance { get; set; }
public decimal CurrentAmount { get; set; } [Parameter] public int? Year { get; set; }
[Parameter] public string AccountName { get; set; } = "Konto";
[Parameter] [Parameter] public EventCallback<decimal> OnSave { get; set; }
public EventCallback<decimal> OnSave { get; set; } [Parameter] public EventCallback OnCancel { get; set; }
private string amountText = string.Empty;
[Parameter] private bool acknowledged;
public EventCallback OnCancel { get; set; } private bool initialized;
private bool saving;
#endregion private bool ValidAmount => BookingInput.TryBalance(amountText, out _);
private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount;
#region Fields private decimal Delta => amount - CurrentAmount;
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
private decimal amount; protected override void OnParametersSet()
{
#endregion if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; }
}
#region Lifecycle private void ChangeAmount(string value) { amountText = value; acknowledged = false; }
private async Task Save()
protected override void OnParametersSet() => amount = CurrentAmount; {
if (saving || !ValidAmount || !acknowledged || Delta == 0m) return;
#endregion saving = true;
try { await OnSave.InvokeAsync(amount); }
#region Actions 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 async Task Save() => await OnSave.InvokeAsync(amount); }
private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask;
private async Task Cancel() => await OnCancel.InvokeAsync();
#endregion
} }
@@ -1,14 +1,15 @@
<div class="dialog-backdrop"> <ModalDialog @ref="modal" Title="Name bearbeiten" OnCancel="OnCancel" Dirty="@(name != CurrentName)">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5 class="mb-3">@DialogTitle</h5> <h5 class="mb-3">@DialogTitle</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true">
<div class="form-container"> <div class="form-container">
<label class="form-label">@NameLabel</label> <label class="form-label" for="edit-name">@NameLabel</label>
<input type="text" class="form-control" @bind="name" @bind:event="oninput" /> <input id="edit-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" />
</div> </div>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button> <button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</div> </form>
</div> </ModalDialog>
@code { private ModalDialog? modal; }
@@ -31,7 +31,8 @@ public partial class EditNameDialog
#region Lifecycle #region Lifecycle
protected override void OnParametersSet() => name = CurrentName; private bool initialized;
protected override void OnParametersSet() { if (!initialized) { name = CurrentName; initialized = true; } }
#endregion #endregion
@@ -45,7 +46,7 @@ public partial class EditNameDialog
} }
} }
private async Task Cancel() => await OnCancel.InvokeAsync(); private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion #endregion
} }
@@ -0,0 +1,76 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
<dialog @ref="element" class="dialog-content app-modal" aria-labelledby="@titleId">
<ToastHost InDialog="true" />
<h2 id="@titleId" class="visually-hidden">@Title</h2>
<div inert="@discardPrompt">@ChildContent</div>
@if (discardPrompt)
{
<section role="alertdialog" aria-label="Ungespeicherte Eingaben">
<h3>Ungespeicherte Eingaben</h3>
<p>Die Änderungen wurden noch nicht gespeichert. Möchtest du weiter bearbeiten oder die Eingaben verwerfen?</p>
<div class="dialog-actions">
<button type="button" class="btn btn-primary" data-discard-continue @onclick="ContinueEditing">Weiter bearbeiten</button>
<button type="button" class="btn btn-outline-danger" @onclick="Discard">Eingaben verwerfen</button>
</div>
</section>
}
</dialog>
@code {
[Parameter] public string Title { get; set; } = "Dialog";
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public bool Dirty { get; set; }
[Parameter] public bool Busy { get; set; }
private readonly string titleId = "dialog-" + Guid.NewGuid().ToString("N");
private ElementReference element;
private DotNetObjectReference<ModalDialog>? reference;
private bool discardPrompt;
private bool focusPrompt;
private bool disposed;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
reference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("duempelkasDialog.open", element, reference);
}
if (focusPrompt)
{
focusPrompt = false;
await JS.InvokeVoidAsync("duempelkasDialog.focus", element, discardPrompt ? "[data-discard-continue]" : "[data-initial-focus],input,select,textarea,button");
}
}
[JSInvokable]
public async Task RequestCloseAsync()
{
if (Busy || disposed) return;
if (Dirty && !discardPrompt)
{
discardPrompt = true;
focusPrompt = true;
await InvokeAsync(StateHasChanged);
}
else if (discardPrompt) await ContinueEditing();
else await OnCancel.InvokeAsync();
}
private Task ContinueEditing()
{
discardPrompt = false;
focusPrompt = true;
return Task.CompletedTask;
}
private Task Discard() => OnCancel.InvokeAsync();
public async ValueTask DisposeAsync()
{
disposed = true;
try { await JS.InvokeVoidAsync("duempelkasDialog.close", element); }
catch (Exception ex) when (ex is JSException or JSDisconnectedException or ObjectDisposedException or TaskCanceledException) { }
reference?.Dispose();
}
}
@@ -20,8 +20,7 @@
@if (showAboutDialog) @if (showAboutDialog)
{ {
<div class="dialog-backdrop"> <ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5>Über Dümpelkas &middot; Version @AppVersion</h5> <h5>Über Dümpelkas &middot; Version @AppVersion</h5>
<p class="mb-2">Entwickler: Andre Beging</p> <p class="mb-2">Entwickler: Andre Beging</p>
<p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p> <p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p>
@@ -30,7 +29,6 @@
<i class="bi bi-x-lg"></i> Schließen <i class="bi bi-x-lg"></i> Schließen
</button> </button>
</div> </div>
</div> </ModalDialog>
</div>
} }
@@ -1,7 +1,7 @@
@implements IDisposable @implements IDisposable
@inject ToastService Toasts @inject ToastService Toasts
<div class="toast-stack" aria-label="Meldungen"> <div class="toast-stack @(InDialog ? "modal-toasts" : "")" aria-label="Meldungen">
@foreach (var toast in Toasts.Messages) @foreach (var toast in Toasts.Messages)
{ {
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()" <section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
@@ -23,6 +23,7 @@
</div> </div>
@code { @code {
[Parameter] public bool InDialog { get; set; }
private readonly HashSet<Guid> running = []; private readonly HashSet<Guid> running = [];
protected override void OnInitialized() => Toasts.Changed += Refresh; protected override void OnInitialized() => Toasts.Changed += Refresh;
private void Refresh() => _ = InvokeAsync(StateHasChanged); private void Refresh() => _ = InvokeAsync(StateHasChanged);
@@ -125,7 +125,7 @@
@if (showEditCarryover) @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" OnSave="HandleSaveCarryover"
OnCancel="() => showEditCarryover = false" /> OnCancel="() => showEditCarryover = false" />
} }
@@ -133,7 +133,7 @@
@if (confirmDeleteEntryId.HasValue) @if (confirmDeleteEntryId.HasValue)
{ {
<ConfirmDialog Title="Buchung löschen" <ConfirmDialog Title="Buchung löschen"
Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")" Message="@(EntryChangeMessage(confirmDeleteEntryId, false))" Busy="entryActionBusy"
ConfirmText="Ja, löschen" ConfirmText="Ja, löschen"
CancelText="Nein, abbrechen" CancelText="Nein, abbrechen"
ConfirmButtonClass="btn btn-danger" ConfirmButtonClass="btn btn-danger"
@@ -145,7 +145,7 @@
@if (confirmRestoreEntryId.HasValue) @if (confirmRestoreEntryId.HasValue)
{ {
<ConfirmDialog Title="Buchung wiederherstellen" <ConfirmDialog Title="Buchung wiederherstellen"
Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")" Message="@(EntryChangeMessage(confirmRestoreEntryId, true))" Busy="entryActionBusy"
ConfirmText="Ja, wiederherstellen" ConfirmText="Ja, wiederherstellen"
CancelText="Nein, abbrechen" CancelText="Nein, abbrechen"
ConfirmButtonClass="btn btn-warning" ConfirmButtonClass="btn btn-warning"
@@ -184,8 +184,7 @@
@if (showYearFilterDialog) @if (showYearFilterDialog)
{ {
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog"> <ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5>Filter auswählen</h5> <h5>Filter auswählen</h5>
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p> <p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
<div class="filter-options-grid"> <div class="filter-options-grid">
@@ -204,7 +203,6 @@
<i class="bi bi-x-lg"></i> Schließen <i class="bi bi-x-lg"></i> Schließen
</button> </button>
</div> </div>
</div> </ModalDialog>
</div>
} }
@@ -30,6 +30,8 @@ public partial class AccountDetail
private int? selectedYear { get => Period.Year; set => Period.Year = value; } private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List<int> availableYears = []; private List<int> availableYears = [];
private bool entryActionBusy;
private Guid? undoToastId;
private int? confirmDeleteEntryId; private int? confirmDeleteEntryId;
private string? confirmDeleteEntryTitle; private string? confirmDeleteEntryTitle;
@@ -108,9 +110,10 @@ public partial class AccountDetail
private async Task HandleSaveCarryover(decimal newAmount) private async Task HandleSaveCarryover(decimal newAmount)
{ {
await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear); await AccountService.CorrectCarryoverAsync(AccountId, newAmount, selectedYear, balance!.CarryoverBalance, account!.CarryoverBalance);
showEditCarryover = false;
await LoadAll(); await LoadAll();
showEditCarryover = false;
Toasts.Show("Bestandskorrektur gespeichert. Die Änderung gilt für alle Jahre.", ToastKind.Success);
} }
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved); private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
@@ -144,19 +147,27 @@ public partial class AccountDetail
private void CancelDeleteConfirm() private void CancelDeleteConfirm()
{ {
if (entryActionBusy) return;
confirmDeleteEntryId = null; confirmDeleteEntryId = null;
confirmDeleteEntryTitle = null; confirmDeleteEntryTitle = null;
} }
private async Task HandleConfirmDelete() private async Task HandleConfirmDelete()
{ {
if (confirmDeleteEntryId.HasValue) if (entryActionBusy || !confirmDeleteEntryId.HasValue) return;
entryActionBusy = true;
try
{ {
await EntryService.DeleteEntryAsync(confirmDeleteEntryId.Value); var id = confirmDeleteEntryId.Value;
await EntryService.DeleteEntryAsync(id);
confirmDeleteEntryId = null; confirmDeleteEntryId = null;
confirmDeleteEntryTitle = null;
await LoadAll(); await LoadAll();
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success,
actionLabel: "Rückgängig", action: () => UndoEntryChange(id, true));
} }
catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { entryActionBusy = false; }
} }
private void RequestRestoreEntry(int entryId) private void RequestRestoreEntry(int entryId)
@@ -167,19 +178,57 @@ public partial class AccountDetail
private void CancelRestoreConfirm() private void CancelRestoreConfirm()
{ {
if (entryActionBusy) return;
confirmRestoreEntryId = null; confirmRestoreEntryId = null;
confirmRestoreEntryTitle = null; confirmRestoreEntryTitle = null;
} }
private async Task HandleConfirmRestore() private async Task HandleConfirmRestore()
{ {
if (confirmRestoreEntryId.HasValue) if (entryActionBusy || !confirmRestoreEntryId.HasValue) return;
entryActionBusy = true;
try
{ {
await EntryService.RestoreEntryAsync(confirmRestoreEntryId.Value); var id = confirmRestoreEntryId.Value;
await EntryService.RestoreEntryAsync(id);
confirmRestoreEntryId = null; confirmRestoreEntryId = null;
confirmRestoreEntryTitle = null;
await LoadAll(); await LoadAll();
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success,
actionLabel: "Rückgängig", action: () => UndoEntryChange(id, false));
} }
catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { entryActionBusy = false; }
}
private async Task UndoEntryChange(int id, bool restore)
{
if (entryActionBusy) return;
entryActionBusy = true;
try
{
var current = (await EntryService.GetEntriesAsync(AccountId, (int?)null)).FirstOrDefault(e => e.Id == id);
if (current == null || current.IsDeleted != restore)
{
Toasts.Show("Die Buchung wurde inzwischen geändert. Bitte die aktuelle Liste prüfen.");
return;
}
if (restore) await EntryService.RestoreEntryAsync(id); else await EntryService.DeleteEntryAsync(id);
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
await LoadAll();
Toasts.Show("Änderung rückgängig gemacht.", ToastKind.Success);
}
finally { entryActionBusy = false; }
}
private string EntryChangeMessage(int? id, bool restore)
{
var entry = entries?.FirstOrDefault(e => e.Id == id);
if (entry == null) return "Bitte die aktuelle Buchung prüfen.";
var description = $"{entry.Date:dd.MM.yyyy}, {entry.Title}, {FormatAmount(entry.Amount)}, Konto {account?.Name}.";
return description + (entry.IsTransfer
? $" Diese Umbuchung betrifft {account?.Name} und {entry.LinkedAccountName}. Die Buchungen auf beiden Konten werden gemeinsam {(restore ? "wiederhergestellt und wieder mitgerechnet" : "gelöscht und nicht mehr mitgerechnet")}."
: $" Die Buchung wird {(restore ? "wiederhergestellt und wieder mitgerechnet" : "gelöscht und nicht mehr mitgerechnet") }.");
} }
private void RequestEditEntry(int entryId) private void RequestEditEntry(int entryId)
+2 -4
View File
@@ -116,8 +116,7 @@
@if (showYearFilterDialog) @if (showYearFilterDialog)
{ {
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog"> <ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5>Filter auswählen</h5> <h5>Filter auswählen</h5>
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p> <p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
<div class="filter-options-grid"> <div class="filter-options-grid">
@@ -136,7 +135,6 @@
<i class="bi bi-x-lg"></i> Schließen <i class="bi bi-x-lg"></i> Schließen
</button> </button>
</div> </div>
</div> </ModalDialog>
</div>
} }
+2 -2
View File
@@ -84,9 +84,9 @@ public partial class Dashboard
#region Actions #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; showAddAccount = false;
await LoadAll(); await LoadAll();
} }
@@ -20,6 +20,14 @@ public static partial class BookingInput
&& amount > 0; && 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", public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default; CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
@@ -8,8 +8,10 @@ public interface IAccountService
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year); Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
Task<AccountSummaryDto> GetAccountAsync(int accountId); Task<AccountSummaryDto> GetAccountAsync(int accountId);
Task<AccountSummaryDto> CreateAccountAsync(string name); Task<AccountSummaryDto> CreateAccountAsync(string name);
Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance);
Task RenameAccountAsync(int accountId, string newName); Task RenameAccountAsync(int accountId, string newName);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial);
Task DeleteAccountAsync(int accountId); Task DeleteAccountAsync(int accountId);
} }
@@ -0,0 +1,3 @@
namespace Duempelkas.App.Services.Models;
public sealed record AccountSetup(string Name, decimal InitialBalance);
@@ -630,3 +630,31 @@ fieldset { min-width: 0; }
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } } @media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
.input-rejection:empty { display: none; } .input-rejection:empty { display: none; }
.app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; }
.app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); }
.dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
.account-card { display: block; width: 100%; color: inherit; text-decoration: none; }
:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
fieldset { border: 0; margin: 0; padding: 0; }
body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
.entry-actions button, .entry-action-menu summary { min-height: 44px; padding: .6rem; }
.entry-action-menu summary { cursor: pointer; border: 1px solid var(--color-border); border-radius: .4rem; }
.entry-action-menu button { display: block; width: 100%; margin-top: .5rem; }
.entry-description { overflow-wrap: anywhere; }
@media (max-width: 900px) {
.entry-list thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
.entry-list table, .entry-list tbody { display: block; width: 100%; }
.entry-list tbody tr { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--color-border); border-radius: .5rem; padding: .6rem; margin-bottom: .75rem; }
.entry-list td { display: block; border: 0; min-width: 0; padding: .3rem; text-align: left !important; }
.entry-list td::before { content: attr(data-label); display: block; color: var(--color-text-muted); font-size: .85rem; }
.entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; }
}
.history-toggle { display: block; padding: .75rem 0; }
.entry-deleted { opacity: 1 !important; }
.deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; }
+13
View File
@@ -17,3 +17,16 @@
if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault(); if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault();
}); });
})(); })();
window.duempelkasDialog = {
open(dialog, receiver) {
const opener = document.activeElement;
dialog.addEventListener('cancel', event => { event.preventDefault(); receiver.invokeMethodAsync('RequestCloseAsync'); });
dialog.addEventListener('close', () => { if (opener?.isConnected) opener.focus(); });
dialog.showModal();
const initial = dialog.querySelector('[data-safe-focus]') || dialog.querySelector('[data-initial-focus]') || dialog.querySelector('input,select,textarea,button');
initial?.focus();
},
close(dialog) { if (dialog?.open) dialog.close(); },
focus(dialog, selector) { dialog.querySelector(selector)?.focus(); }
};
@@ -93,15 +93,19 @@ public class AccountService : IAccountService
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc); return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
} }
public async Task<AccountSummaryDto> CreateAccountAsync(string name) public Task<AccountSummaryDto> CreateAccountAsync(string name) => CreateAccountAsync(name, 0m);
public async Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance)
{ {
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Bitte einen Kontonamen eingeben.");
if (decimal.Round(initialBalance, 2) != initialBalance) throw new ArgumentException("Ungültiger Anfangsbestand.");
using var lease = await DatabaseLease.AcquireAsync(_location); using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var account = new Account { Name = name }; var account = new Account { Name = name.Trim(), CarryoverBalance = initialBalance };
db.Accounts.Add(account); db.Accounts.Add(account);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc); return new AccountSummaryDto(account.Id, account.Name, initialBalance, initialBalance, account.CreatedUtc);
} }
public async Task RenameAccountAsync(int accountId, string newName) public async Task RenameAccountAsync(int accountId, string newName)
@@ -148,6 +152,22 @@ public class AccountService : IAccountService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial)
{
if (decimal.Round(value, 2) != value) throw new ArgumentException("Ungültiger Bestand.");
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var account = await db.Accounts.FindAsync(accountId) ?? throw new InvalidOperationException("Konto nicht mehr vorhanden.");
var movement = year.HasValue ? await db.Entries.Where(e => e.AccountId == accountId && !e.IsDeleted && e.Date.Year < year.Value)
.SumAsync(e => e.Type == EntryType.Income ? e.Amount : -e.Amount) : 0m;
if (account.CarryoverBalance != expectedInitial || account.CarryoverBalance + movement != expectedCarryover)
throw new InvalidOperationException("Der Bestand wurde inzwischen geändert. Bitte abbrechen und die Korrektur mit aktuellen Werten erneut öffnen.");
account.CarryoverBalance = value - movement;
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
public async Task DeleteAccountAsync(int accountId) public async Task DeleteAccountAsync(int accountId)
{ {
using var lease = await DatabaseLease.AcquireAsync(_location); using var lease = await DatabaseLease.AcquireAsync(_location);
@@ -270,6 +270,11 @@ public class EntryService : IEntryService
var otherEntry = await db.Entries.FindAsync(otherEntryId) var otherEntry = await db.Entries.FindAsync(otherEntryId)
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden."); ?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
if (newLinkedAccountId == entry.AccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
if (!await db.Accounts.AnyAsync(a => a.Id == newLinkedAccountId))
throw new InvalidOperationException("Das gewählte Konto ist nicht mehr vorhanden.");
// Update date, title, amount on both sides // Update date, title, amount on both sides
entry.Date = date; entry.Date = date;
entry.Title = title.Trim(); entry.Title = title.Trim();
@@ -15,6 +15,7 @@ public class BookingFormTests
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput() public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
{ {
using var context = new BunitContext(); using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>(); context.Services.AddSingleton<ToastService>();
context.Services.AddSingleton<IEntryService>(new EntryService(null!)); context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse")); var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
@@ -40,6 +41,7 @@ public class BookingFormTests
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput() public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
{ {
using var context = new BunitContext(); using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>(); context.Services.AddSingleton<ToastService>();
context.Services.AddSingleton<IEntryService>(new EntryService(null!)); context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null); var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
@@ -0,0 +1,48 @@
using Bunit;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.Domain.Enums;
using Xunit;
namespace Duempelkas.Tests;
public class CarryoverCorrectionTests
{
[Fact]
public async Task Correction_ShiftsAllYearsByDelta_AndRejectsStaleState()
{
using var f = new LedgerUiFixture();
var accounts = f.Service<IAccountService>();
var entries = f.Service<IEntryService>();
var balances = f.Service<IBalanceQueryService>();
await entries.CreateEntryAsync(1, EntryType.Income, new(2025, 1, 1), "Alt", 10m);
await entries.CreateEntryAsync(1, EntryType.Expense, new(2026, 1, 1), "Neu", 5m);
await accounts.CorrectCarryoverAsync(1, 120m, 2026, 110m, 100m);
Assert.Equal(110m, (await accounts.GetAccountAsync(1)).CarryoverBalance);
Assert.Equal(120m, (await balances.GetAccountBalanceAsync(1, 2025)).TotalBalance);
Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2026)).TotalBalance);
Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2027)).TotalBalance);
Assert.Equal(2, (await entries.GetEntriesAsync(1, (int?)null)).Count);
await Assert.ThrowsAsync<InvalidOperationException>(() => accounts.CorrectCarryoverAsync(1, 130m, 2026, 110m, 100m));
var newAccount = await accounts.CreateAccountAsync("Neues Konto", -20m);
Assert.Equal(-20m, newAccount.TotalBalance);
Assert.Empty(await entries.GetEntriesAsync(newAccount.Id, (int?)null));
}
[Fact]
public void CorrectionForm_ShowsBeforeAfter_AndRequiresAcknowledgement()
{
using var f = new LedgerUiFixture();
var saved = 0m;
var dialog = f.Context.Render<EditCarryoverDialog>(p => p.Add(x => x.CurrentAmount, 110m)
.Add(x => x.InitialBalance, 100m).Add(x => x.Year, 2026).Add(x => x.AccountName, "Barkasse")
.Add(x => x.OnSave, value => saved = value));
dialog.Find("#carryover").Input("120,00");
Assert.Contains("frühere und spätere Jahre", dialog.Markup);
Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("input[type=checkbox]").Change(true);
dialog.Find("button.btn-success").Click();
Assert.Equal(120m, saved);
}
}
@@ -0,0 +1,32 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Services;
using Xunit;
namespace Duempelkas.Tests;
public class EntryLifecycleUiTests
{
[Fact]
public async Task DeleteTransfer_ExplainsBothAccounts_HidesRows_AndUndoRestoresBoth()
{
using var f = new LedgerUiFixture();
var entries = f.Service<IEntryService>();
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m);
var page = f.Context.Render<Routes>();
page.WaitForElement(".account-card").Click();
page.WaitForElement(".entry-action-menu");
page.Find("button.btn-outline-danger").Click();
page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup));
Assert.Contains("Barkasse", page.Find("dialog").TextContent);
Assert.Contains("Girokonto", page.Find("dialog").TextContent);
page.Find("dialog button.btn-danger").Click();
page.WaitForAssertion(() => Assert.Empty(page.FindAll("tbody tr")));
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.True(e.IsDeleted));
page.Find(".history-toggle input").Change(true);
Assert.Contains("zählt nicht zum Kontostand", page.Markup);
page.Find(".toast-success .btn").Click();
page.WaitForAssertion(() => Assert.Single(page.FindAll("tbody tr")));
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted));
}
}
@@ -0,0 +1,24 @@
using Bunit;
using Duempelkas.App.Components.Accounts;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Enums;
using Xunit;
namespace Duempelkas.Tests;
public class EntryPresentationTests
{
[Fact]
public void Actions_HaveVisibleTextAndBookingSpecificNames()
{
using var context = new BunitContext();
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, DateTime.Today, "Porto", 4.20m, false, false, null, null, null);
var edited = 0;
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id));
Assert.Contains("2026-001 Porto", table.Find("summary").GetAttribute("aria-label"));
var edit = table.FindAll("button").Single(b => b.TextContent.Contains("Bearbeiten"));
Assert.Contains("Porto", edit.GetAttribute("aria-label"));
edit.Click();
Assert.Equal(1, edited);
}
}
+33
View File
@@ -0,0 +1,33 @@
using Bunit;
using Duempelkas.Infrastructure;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Duempelkas.Tests;
internal sealed class LedgerUiFixture : IDisposable
{
public BunitContext Context { get; } = new();
private readonly SqliteConnection anchor;
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-ui-" + Guid.NewGuid().ToString("N"));
public LedgerUiFixture(bool accounts = true)
{
Directory.CreateDirectory(folder);
var connection = $"Data Source=ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
anchor = new SqliteConnection(connection);
anchor.Open();
Context.Services.AddInfrastructure(connection, folder);
Context.JSInterop.Mode = JSRuntimeMode.Loose;
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
db.Database.EnsureCreated();
if (accounts)
{
db.Accounts.AddRange(new() { Name = "Barkasse", CarryoverBalance = 100m }, new() { Name = "Girokonto" });
db.SaveChanges();
}
}
public T Service<T>() where T : notnull => Context.Services.GetRequiredService<T>();
public void Dispose() { Context.Dispose(); anchor.Dispose(); Directory.Delete(folder, true); }
}
@@ -0,0 +1,44 @@
using Bunit;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class ModalDialogTests
{
[Fact]
public async Task EscapeRequest_ClosesCleanDialog_ButProtectsDirtyInput()
{
using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>();
var cancelled = 0;
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Title, "Testdialog").Add(x => x.OnCancel, () => cancelled++));
Assert.NotNull(modal.Find("dialog").GetAttribute("aria-labelledby"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Equal(1, cancelled);
modal.Render(p => p.Add(x => x.Dirty, true));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Equal(1, cancelled);
Assert.Contains("Ungespeicherte Eingaben", modal.Markup);
modal.Find("[data-discard-continue]").Click();
Assert.Empty(modal.FindAll("[role=alertdialog]"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
modal.Find("button.btn-outline-danger").Click();
Assert.Equal(2, cancelled);
}
[Fact]
public async Task BusyDialog_CannotClose()
{
using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>();
var cancelled = false;
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Busy, true).Add(x => x.OnCancel, () => cancelled = true));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.False(cancelled);
}
}
@@ -0,0 +1,41 @@
using Bunit;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.Domain.Enums;
using Xunit;
namespace Duempelkas.Tests;
public class TransferDirectionTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task BothSides_ShowSameDirection(int accountId)
{
using var f = new LedgerUiFixture();
var entries = f.Service<IEntryService>();
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
var entry = Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
var dialog = f.Context.Render<AddTransferDialog>(p => p.Add(x => x.SourceAccountId, accountId).Add(x => x.EditEntry, entry));
dialog.WaitForAssertion(() => Assert.Contains("Von: Barkasse → Nach: Girokonto", dialog.Find("#transfer-direction").TextContent));
Assert.Contains("Gesamtsumme unverändert", dialog.Markup);
Assert.Contains(accountId == 2 ? "Von Konto" : "Nach Konto", dialog.Find("label[for=transfer-account]").TextContent);
}
[Fact]
public async Task IncomingEdit_MovesSource_AndRejectsSameAccountWithoutChanges()
{
using var f = new LedgerUiFixture();
var entries = f.Service<IEntryService>();
var third = await f.Service<IAccountService>().CreateAccountAsync("Sparkonto");
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
var incoming = Assert.Single(await entries.GetEntriesAsync(2, (int?)null));
await Assert.ThrowsAsync<InvalidOperationException>(() => entries.UpdateTransferAsync(incoming.Id, 2, DateTime.Today, "Unzulässig", 99m));
Assert.Equal(20m, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Amount);
await entries.UpdateTransferAsync(incoming.Id, third.Id, DateTime.Today, "Andere Quelle", 25m);
Assert.Empty(await entries.GetEntriesAsync(1, (int?)null));
Assert.Equal(EntryType.Expense, Assert.Single(await entries.GetEntriesAsync(third.Id, (int?)null)).Type);
Assert.Equal(EntryType.Income, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Type);
}
}