Compare commits
4
Commits
122d4de809
...
22e509d982
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e509d982 | ||
|
|
76ba79b63e | ||
|
|
33a8abf5cc | ||
|
|
9c41856b5b |
@@ -27,3 +27,25 @@ Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen
|
||||
## U03 – Gleiche Bedeutung in PDF und Oberfläche
|
||||
|
||||
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
|
||||
|
||||
## U04 – Jahreskontext
|
||||
|
||||
Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück erhalten, auch „Alle Jahre“. Auswahl enthält aktuelles und gewähltes Jahr auch ohne Buchungen. Liste und Summen benennen den Zeitraum; Leerzustände unterscheiden fehlende Buchungen und leeren Zeitraum. Abweichendes Datum bleibt unverändert, erfordert eine gezielte Bestätigung im Formular und bietet nach Erfolg „Buchung im Jahr … anzeigen“. Prüfung: 21/21 Debug-Tests, einschließlich gerenderter Navigation 2025 → Konto → Zurück, Buchung 2026 bei Filter 2025 und anschließendem Aufruf des gespeicherten Jahres. Dabei bestehende doppelte DbContext-Registrierung korrigiert, da die Singleton-Factory sonst scoped Optionen konsumiert.
|
||||
|
||||
## U05 – Alltagssprache
|
||||
|
||||
Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ausführlich. Flexible Breiten und Zeilenumbruch ersetzen die feste 75-px-Breite. Rückspielen liegt weiterhin auffindbar unter „Weitere Aktionen“ und erklärt Umfang und Rückfallsicherung. „Kontostand“ und „Änderung im Jahr“ ersetzen Fachbegriffe. Umbuchung erklärt ausdrücklich, dass keine Banküberweisung ausgelöst wird. Anrede in den betroffenen Abläufen: du. Prüfung: 21/21 Debug-Tests; native Sichtprüfung folgt im Abschluss. Ein Nutzertest mit der Zielgruppe ist dadurch nicht ersetzt.
|
||||
|
||||
## U06 – Verständliche Eingabe
|
||||
|
||||
Konto im Dialogtitel, sichtbare Art beim Bearbeiten und erklärter Korrekturweg: abbrechen, falsche Buchung löschen, neu erfassen. Keine Änderung der Buchungsart bestehender Daten. Feldnahe, technisch zugeordnete Hinweise für Beschreibung, Datum und Betrag. Komma/Punkt sowie eingefügte einfache Beträge mit Rand-Leerzeichen werden akzeptiert; Minus, Tausendertrenner, Währungszeichen, Überlauf und mehr als zwei Nachkommastellen abgewiesen, ohne Rundung. Dienste prüfen dieselben fachlichen Mindestregeln. Nach erneutem Rendern bleiben geänderte Eingaben erhalten. Prüfung: 35/35 Debug-Tests, darunter Parsing-Grenzfälle und gerenderte Formularprüfung mit Korrektur ungültiger Eingaben. Längere Buchungsdialoge können innerhalb des Fensters scrollen.
|
||||
|
||||
## U07 – Speichern und Mehrfachauslösung
|
||||
|
||||
Beide Buchungsdialoge sperren Formular und Aktionen während des Speicherns und zeigen „Wird gespeichert …“. Submit (einschließlich Enter) verwendet denselben geschützten Ablauf. Nach Erfolg meldet die Kontoseite Art und Betrag und hebt die betroffene Zeile kurz hervor. Transfererfassung liefert die eindeutige Quellbuchungs-ID zurück. Fehler halten Eingaben im geöffneten Dialog; ein fehlgeschlagener Seitenrefresh nach bereits bestätigtem Schreiben kann ausschließlich den Refresh wiederholen. Eine bewusste weitere Erfassung bleibt möglich. Optionale Serienerfassung wurde nicht hinzugefügt.
|
||||
|
||||
Prüfung: 40/40 Tests in Debug und Release. Verzögerte normale Buchung und Umbuchung mit mehrfachen Submit-Ereignissen erzeugen jeweils genau eine fachliche Buchung; zweite bewusste Erfassung funktioniert. Simulierte Fehler behalten Beschreibung und Dezimaltext, erzeugen keine falsche Erfolgsmeldung und erlauben Retry. Gesonderter Test prüft Refreshfehler nach Commit ohne erneutes Schreiben. Jahresnavigation einschließlich „Alle Jahre“ ist ebenfalls gerendert geprüft.
|
||||
|
||||
## Abschluss und Grenzen
|
||||
|
||||
Desktop-Projekte in Debug und Release gebaut, jeweils ohne Warnungen und Fehler. PDF-Inhalte und gerenderte Seiten sind geprüft. Die neue Windows-Debug-Anwendung wurde gestartet und ihr Accessibility-Baum mit den neuen Beschriftungen gelesen. **Die native visuelle/Interaktionsprüfung konnte nicht abgeschlossen werden: Windows war gesperrt; die Aufnahme zeigte den Sperrbildschirm.** Es wurden dort keine Buchungen angelegt oder Sicherungen zurückgespielt. Desktop-Sichtprüfung der umbrechenden Leisten, native Dateidialoge und reale Hilfstechnologien bleiben manuelle Abnahmepunkte. Die automatisierten Formular-/Navigationsprüfungen ersetzen keinen Test mit der Zielgruppe und keine vollständige U08-/U13-Abnahme.
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class EntryRow
|
||||
{
|
||||
[Parameter] public bool Highlighted { get; set; }
|
||||
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
@@ -26,6 +28,7 @@ public partial class EntryRow
|
||||
private string GetRowClass()
|
||||
{
|
||||
var classes = new List<string>();
|
||||
if (Highlighted) classes.Add("entry-recent");
|
||||
if (Entry.IsTransfer)
|
||||
{
|
||||
classes.Add("entry-row-transfer");
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<tbody>
|
||||
@foreach (var entry in Entries)
|
||||
{
|
||||
<EntryRow Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
||||
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -20,6 +20,6 @@
|
||||
|
||||
@if (!Entries.Any())
|
||||
{
|
||||
<div class="text-center py-3 text-muted">Keine Buchungen vorhanden.</div>
|
||||
<div class="text-center py-3 text-muted">@EmptyMessage</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class EntryTable
|
||||
{
|
||||
[Parameter] public int? HighlightedEntryId { get; set; }
|
||||
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
|
||||
@@ -1,36 +1,68 @@
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
|
||||
<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>
|
||||
|
||||
<div class="form-container">
|
||||
@if (EditEntry == null)
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||
<div class="form-container">
|
||||
<label class="form-label" for="entry-type">Art</label>
|
||||
@if (EditEntry == null)
|
||||
{
|
||||
<select id="entry-type" @bind="entryType">
|
||||
<option value="@EntryType.Income">Einnahme</option>
|
||||
<option value="@EntryType.Expense">Ausgabe</option>
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div><strong>@(entryType == EntryType.Income ? "Einnahme" : "Ausgabe")</strong>
|
||||
<p class="text-muted">Falsche Art? Diese Buchung abbrechen, in der Kontoliste löschen und mit der richtigen Art neu erfassen.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="form-label" for="entry-date">Datum</label>
|
||||
<div>
|
||||
<input id="entry-date" type="date" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" aria-describedby="entry-date-help" aria-invalid="@(date == default ? "true" : "false")" />
|
||||
@if (date == default) { <p id="entry-date-help" class="text-danger">Bitte ein gültiges Datum eingeben.</p> }
|
||||
</div>
|
||||
|
||||
<label class="form-label" for="entry-title">Beschreibung</label>
|
||||
<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")" />
|
||||
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
|
||||
</div>
|
||||
|
||||
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||
<div>
|
||||
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
|
||||
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (OutsideYear && date != default)
|
||||
{
|
||||
<div class="alert alert-warning" role="status">
|
||||
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<label class="form-label">Art</label>
|
||||
<select @bind="entryType">
|
||||
<option value="@EntryType.Income">Einnahme</option>
|
||||
<option value="@EntryType.Expense">Ausgabe</option>
|
||||
</select>
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="form-label">Datum</label>
|
||||
<input type="date" @bind="date" />
|
||||
|
||||
<label class="form-label">Bezeichnung</label>
|
||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"
|
||||
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)">
|
||||
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,19 +15,38 @@ public partial class AddEntryDialog
|
||||
public EntryDto? EditEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
[Parameter] public int? ContextYear { get; set; }
|
||||
private bool confirmDifferentYear;
|
||||
private int? confirmedYear;
|
||||
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
||||
|
||||
[Parameter] public string AccountName { get; set; } = string.Empty;
|
||||
private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
||||
|
||||
private bool isSaving;
|
||||
private bool completed;
|
||||
private BookingSaved? committedResult;
|
||||
private string? saveError;
|
||||
private string? saveDetails;
|
||||
|
||||
#region Fields
|
||||
|
||||
private EntryType entryType = EntryType.Income;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
|
||||
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
private string amountText = string.Empty;
|
||||
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 bool initialized;
|
||||
private int? initializedEntryId;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -35,12 +54,15 @@ public partial class AddEntryDialog
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||
initialized = true;
|
||||
initializedEntryId = EditEntry?.Id;
|
||||
if (EditEntry != null)
|
||||
{
|
||||
entryType = EditEntry.Type;
|
||||
date = EditEntry.Date;
|
||||
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,19 +72,40 @@ public partial class AddEntryDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
try
|
||||
{
|
||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||
if (committedResult == null)
|
||||
{
|
||||
var id = EditEntry?.Id ?? 0;
|
||||
if (EditEntry != null)
|
||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||
else
|
||||
id = (await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount)).Id;
|
||||
committedResult = new(id, date, entryType == EntryType.Income ? "Einnahme" : "Ausgabe", amount, EditEntry != null);
|
||||
}
|
||||
// If refreshing the page fails after commit, retry only notification,
|
||||
// never create a second booking.
|
||||
await OnSave.InvokeAsync(committedResult);
|
||||
completed = true;
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
|
||||
saveError = committedResult == null
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
}
|
||||
|
||||
await OnSave.InvokeAsync();
|
||||
finally { isSaving = false; }
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (!isSaving) await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -2,34 +2,57 @@
|
||||
@inject IAccountService AccountService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
|
||||
|
||||
<div class="form-container">
|
||||
<label class="form-label">Zielkonto</label>
|
||||
<select class="form-select" @bind="targetAccountId">
|
||||
<option value="0">— Auswählen —</option>
|
||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
||||
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
|
||||
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||
<div class="form-container">
|
||||
<label class="form-label">Zielkonto</label>
|
||||
<select id="transfer-account" class="form-select" @bind="targetAccountId">
|
||||
<option value="0">— Auswählen —</option>
|
||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
||||
{
|
||||
<option value="@acc.Id">@acc.Name</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<label class="form-label">Datum</label>
|
||||
<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>
|
||||
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<div><input id="transfer-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
|
||||
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
|
||||
</div>
|
||||
|
||||
@if (OutsideYear)
|
||||
{
|
||||
<option value="@acc.Id">@acc.Name</option>
|
||||
<div class="alert alert-warning" role="status">
|
||||
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
|
||||
</div>
|
||||
}
|
||||
</select>
|
||||
|
||||
<label class="form-label">Datum</label>
|
||||
<input type="date" class="form-control" @bind="date" />
|
||||
|
||||
<label class="form-label">Bezeichnung</label>
|
||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"
|
||||
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,26 +14,42 @@ public partial class AddTransferDialog
|
||||
public EntryDto? EditEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
[Parameter] public int? ContextYear { get; set; }
|
||||
private bool confirmDifferentYear;
|
||||
private int? confirmedYear;
|
||||
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
||||
|
||||
private bool isSaving;
|
||||
private bool completed;
|
||||
private BookingSaved? committedResult;
|
||||
private string? saveError;
|
||||
private string? saveDetails;
|
||||
|
||||
#region Fields
|
||||
|
||||
private List<AccountSummaryDto> accounts = new();
|
||||
private int targetAccountId;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
|
||||
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
private string amountText = string.Empty;
|
||||
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 bool initialized;
|
||||
private int? initializedEntryId;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0;
|
||||
private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -46,12 +62,15 @@ public partial class AddTransferDialog
|
||||
accounts = await AccountService.GetAllAccountsAsync();
|
||||
}
|
||||
|
||||
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||
initialized = true;
|
||||
initializedEntryId = EditEntry?.Id;
|
||||
if (EditEntry != null)
|
||||
{
|
||||
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
||||
date = EditEntry.Date;
|
||||
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,19 +80,40 @@ public partial class AddTransferDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
try
|
||||
{
|
||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||
if (committedResult == null)
|
||||
{
|
||||
var id = EditEntry?.Id ?? 0;
|
||||
if (EditEntry != null)
|
||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||
else
|
||||
id = await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
|
||||
committedResult = new(id, date, "Umbuchung", amount, EditEntry != null);
|
||||
}
|
||||
// If refreshing the page fails after commit, retry only notification,
|
||||
// never create a second booking.
|
||||
await OnSave.InvokeAsync(committedResult);
|
||||
completed = true;
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
|
||||
saveError = committedResult == null
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
}
|
||||
|
||||
await OnSave.InvokeAsync();
|
||||
finally { isSaving = false; }
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (!isSaving) await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ReportingPeriod Period
|
||||
@page "/accounts/{AccountId:int}"
|
||||
@inject IAccountService AccountService
|
||||
@inject IEntryService EntryService
|
||||
@@ -10,6 +11,19 @@
|
||||
<OperationNotice Result="fileResult" />
|
||||
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||
|
||||
@if (lastSaved != null)
|
||||
{
|
||||
<div class="alert alert-success" role="status" aria-live="polite">
|
||||
@lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert").
|
||||
</div>
|
||||
}
|
||||
@if (savedOutsideYear.HasValue)
|
||||
{
|
||||
<div class="alert alert-info" role="status">
|
||||
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
|
||||
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
|
||||
</div>
|
||||
}
|
||||
@if (account == null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
@@ -31,28 +45,27 @@
|
||||
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
|
||||
<i class="bi bi-plus-lg"></i> Buchung
|
||||
<i class="bi bi-plus-lg"></i> Einnahme oder Ausgabe erfassen
|
||||
</button>
|
||||
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
||||
<i class="bi bi-arrow-left-right"></i> Umbuchung
|
||||
<i class="bi bi-arrow-left-right"></i> Geld zwischen Konten verschieben
|
||||
</button>
|
||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i> @GetFilterLabel()
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted">Zeitraum für Liste und Summen: @Period.Label</p>
|
||||
@if (balance != null)
|
||||
{
|
||||
<div class="summary-section">
|
||||
<div class="summary-flex">
|
||||
<div>
|
||||
<small class="text-muted">Saldo</small>
|
||||
<small class="text-muted">Kontostand</small>
|
||||
<div class="fs-3 fw-bold @(balance.TotalBalance >= 0 ? "amount-positive" : "amount-negative")">
|
||||
@FormatAmount(balance.TotalBalance)
|
||||
</div>
|
||||
@@ -82,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Umsätze @selectedYear.Value</small>
|
||||
<small class="text-muted">Änderung im Jahr @selectedYear.Value</small>
|
||||
<div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
|
||||
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
|
||||
</div>
|
||||
@@ -96,7 +109,7 @@
|
||||
@if (entries != null)
|
||||
{
|
||||
<div class="entries-scroll-wrapper">
|
||||
<EntryTable Entries="entries" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
|
||||
<EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -104,14 +117,14 @@
|
||||
|
||||
@if (showAddEntry)
|
||||
{
|
||||
<AddEntryDialog AccountId="AccountId"
|
||||
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||
OnSave="HandleEntryCreated"
|
||||
OnCancel="() => showAddEntry = false" />
|
||||
}
|
||||
|
||||
@if (showAddTransfer)
|
||||
{
|
||||
<AddTransferDialog SourceAccountId="AccountId"
|
||||
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
|
||||
OnSave="HandleTransferCreated"
|
||||
OnCancel="() => showAddTransfer = false" />
|
||||
}
|
||||
@@ -157,7 +170,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
<ConfirmDialog Title="PDF öffnen"
|
||||
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||
Message="Die PDF wurde gespeichert. Möchtest du sie jetzt öffnen?"
|
||||
ConfirmText="Ja, öffnen"
|
||||
CancelText="Nein"
|
||||
ConfirmButtonClass="btn btn-primary"
|
||||
@@ -168,7 +181,7 @@
|
||||
|
||||
@if (editingEntry != null)
|
||||
{
|
||||
<AddEntryDialog AccountId="AccountId"
|
||||
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||
EditEntry="editingEntry"
|
||||
OnSave="HandleEntryEdited"
|
||||
OnCancel="() => editingEntry = null" />
|
||||
@@ -176,7 +189,7 @@
|
||||
|
||||
@if (editingTransferEntry != null)
|
||||
{
|
||||
<AddTransferDialog SourceAccountId="AccountId"
|
||||
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
|
||||
EditEntry="editingTransferEntry"
|
||||
OnSave="HandleEntryEdited"
|
||||
OnCancel="() => editingTransferEntry = null" />
|
||||
|
||||
@@ -22,7 +22,7 @@ public partial class AccountDetail
|
||||
private bool showEditName;
|
||||
private bool showEditCarryover;
|
||||
private bool showYearFilterDialog;
|
||||
private int? selectedYear = DateTime.Now.Year;
|
||||
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||
private List<int> availableYears = [];
|
||||
|
||||
private int? confirmDeleteEntryId;
|
||||
@@ -35,6 +35,9 @@ public partial class AccountDetail
|
||||
private FileOperationResult? fileResult;
|
||||
private bool fileBusy;
|
||||
|
||||
private int? savedOutsideYear;
|
||||
private BookingSaved? lastSaved;
|
||||
private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
|
||||
private EntryDto? editingEntry;
|
||||
private EntryDto? editingTransferEntry;
|
||||
|
||||
@@ -62,7 +65,9 @@ public partial class AccountDetail
|
||||
account = await AccountService.GetAccountAsync(AccountId);
|
||||
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
|
||||
entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
|
||||
availableYears = await EntryService.GetEntryYearsAsync(AccountId);
|
||||
var entryYears = await EntryService.GetEntryYearsAsync(AccountId);
|
||||
availableYears = Period.Choices(entryYears);
|
||||
emptyEntriesMessage = entryYears.Count == 0 ? "Noch keine Buchungen vorhanden." : "Keine Buchungen in diesem Zeitraum. Wähle ein anderes Jahr oder Alle Jahre.";
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
@@ -78,6 +83,7 @@ public partial class AccountDetail
|
||||
private async Task SelectFilterYear(int? year)
|
||||
{
|
||||
selectedYear = year;
|
||||
savedOutsideYear = null;
|
||||
showYearFilterDialog = false;
|
||||
await LoadAll();
|
||||
}
|
||||
@@ -100,17 +106,22 @@ public partial class AccountDetail
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleEntryCreated()
|
||||
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
|
||||
private Task HandleTransferCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
|
||||
private async Task HandleBookingSaved(BookingSaved saved)
|
||||
{
|
||||
showAddEntry = false;
|
||||
await LoadAll();
|
||||
lastSaved = saved;
|
||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null;
|
||||
showAddEntry = false;
|
||||
showAddTransfer = false;
|
||||
editingEntry = null;
|
||||
editingTransferEntry = null;
|
||||
}
|
||||
|
||||
private async Task HandleTransferCreated()
|
||||
{
|
||||
showAddTransfer = false;
|
||||
await LoadAll();
|
||||
}
|
||||
private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
|
||||
|
||||
private void RequestDeleteEntry(int entryId)
|
||||
{
|
||||
@@ -171,12 +182,7 @@ public partial class AccountDetail
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEntryEdited()
|
||||
{
|
||||
editingEntry = null;
|
||||
editingTransferEntry = null;
|
||||
await LoadAll();
|
||||
}
|
||||
private Task HandleEntryEdited(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
|
||||
private async Task HandleExport()
|
||||
{
|
||||
@@ -224,9 +230,9 @@ public partial class AccountDetail
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
|
||||
private static string FormatAmount(decimal amount) => amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
|
||||
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
|
||||
private string GetFilterLabel() => Period.Label;
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ReportingPeriod Period
|
||||
@page "/"
|
||||
@inject IAccountService AccountService
|
||||
@inject IBackupService BackupService
|
||||
@@ -27,30 +28,31 @@
|
||||
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
|
||||
<i class="bi bi-plus-lg"></i> Neues Konto
|
||||
</button>
|
||||
|
|
||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-save"></i> Backup
|
||||
<i class="bi bi-save"></i> Sicherung erstellen
|
||||
</button>
|
||||
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||
</button>
|
||||
|
|
||||
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i> @GetFilterLabel()
|
||||
</button>
|
||||
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
|
||||
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
|
||||
</button>
|
||||
<details class="more-actions">
|
||||
<summary class="btn btn-outline-secondary">Weitere Aktionen</summary>
|
||||
<button class="btn btn-warning mt-2" @onclick="HandleRestoreAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Sicherung zurückspielen
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@if (accounts != null && accounts.Any())
|
||||
{
|
||||
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
|
||||
<strong>Summe aller Konten:</strong>
|
||||
<strong>Summe aller Konten – @Period.Label:</strong>
|
||||
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
|
||||
@FormatCurrency(TotalClubBalance)
|
||||
</span>
|
||||
@@ -91,7 +93,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
<ConfirmDialog Title="PDF öffnen"
|
||||
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||
Message="Die PDF wurde gespeichert. Möchtest du sie jetzt öffnen?"
|
||||
ConfirmText="Ja, öffnen"
|
||||
CancelText="Nein"
|
||||
ConfirmButtonClass="btn btn-primary"
|
||||
@@ -102,8 +104,8 @@
|
||||
|
||||
@if (showRestoreConfirm)
|
||||
{
|
||||
<ConfirmDialog Title="Restore bestätigen"
|
||||
Message="Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?"
|
||||
<ConfirmDialog Title="Sicherung zurückspielen"
|
||||
Message="Die Buchungen und Konten werden durch die gewählte Sicherung ersetzt. Vorher wird eine Rückfallsicherung erstellt. Vereinsname und Darstellung sind nicht enthalten. Möchtest du fortfahren?"
|
||||
ConfirmText="Ja, wiederherstellen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-warning"
|
||||
|
||||
@@ -14,7 +14,7 @@ public partial class Dashboard
|
||||
private bool showEditClubName;
|
||||
private bool showRestoreConfirm;
|
||||
private bool showYearFilterDialog;
|
||||
private int? selectedYear = DateTime.Now.Year;
|
||||
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||
private List<int> availableYears = [];
|
||||
private string clubName = string.Empty;
|
||||
private string? savedPdfPath;
|
||||
@@ -67,7 +67,7 @@ public partial class Dashboard
|
||||
private async Task LoadAll()
|
||||
{
|
||||
await LoadAccounts();
|
||||
availableYears = await EntryService.GetAllEntryYearsAsync();
|
||||
availableYears = Period.Choices(await EntryService.GetAllEntryYearsAsync());
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
@@ -207,7 +207,7 @@ public partial class Dashboard
|
||||
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
}
|
||||
|
||||
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
|
||||
private string GetFilterLabel() => Period.Label;
|
||||
|
||||
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public static partial class BookingInput
|
||||
{
|
||||
[GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AmountPattern();
|
||||
|
||||
public static bool TryAmount(string text, out decimal amount)
|
||||
{
|
||||
amount = 0;
|
||||
var plain = text.Trim();
|
||||
return AmountPattern().IsMatch(plain)
|
||||
&& decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount)
|
||||
&& amount > 0;
|
||||
}
|
||||
|
||||
public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
|
||||
|
||||
public static void Validate(DateTime date, string title, decimal amount)
|
||||
{
|
||||
if (date == default) throw new ArgumentException("Bitte ein gültiges Datum eingeben.");
|
||||
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Bitte eine Beschreibung eingeben.");
|
||||
if (amount <= 0 || decimal.Round(amount, 2) != amount)
|
||||
throw new ArgumentException("Betrag ohne Minuszeichen, größer als 0 und mit höchstens zwei Nachkommastellen eingeben.");
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public interface IEntryService
|
||||
Task<List<int>> GetEntryYearsAsync(int accountId);
|
||||
Task<List<int>> GetAllEntryYearsAsync();
|
||||
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
|
||||
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
||||
Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
||||
Task DeleteEntryAsync(int entryId);
|
||||
Task RestoreEntryAsync(int entryId);
|
||||
Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Duempelkas.App.Services.Models;
|
||||
|
||||
public sealed record BookingSaved(int EntryId, DateTime Date, string Kind, decimal Amount, bool Edited);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
/// <summary>The user's explicit selection survives navigation, including All years.</summary>
|
||||
public sealed class ReportingPeriod
|
||||
{
|
||||
public int? Year { get; set; } = DateTime.Today.Year;
|
||||
public string Label => Year.HasValue ? $"Jahr: {Year}" : "Alle Jahre";
|
||||
public List<int> Choices(IEnumerable<int> entryYears) => entryYears
|
||||
.Append(DateTime.Today.Year).Concat(Year.HasValue ? [Year.Value] : Array.Empty<int>())
|
||||
.Distinct().OrderDescending().ToList();
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class Program
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht ?bernommen werden. Der Start wurde abgebrochen; die urspr?ngliche Datei bleibt erhalten.", ex);
|
||||
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht übernommen werden. Der Start wurde abgebrochen; die ursprüngliche Datei bleibt erhalten.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,8 +217,14 @@ html, body {
|
||||
}
|
||||
|
||||
.btn-nav {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
width: auto;
|
||||
min-width: 7rem;
|
||||
max-width: 15rem;
|
||||
min-height: 75px;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -588,3 +594,21 @@ input[type="number"].no-spinner {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.more-actions { align-self: center; }
|
||||
.more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; }
|
||||
|
||||
.form-container > div { min-width: 0; }
|
||||
.form-container p { margin: .35rem 0 0; font-size: .9rem; }
|
||||
|
||||
.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
|
||||
|
||||
fieldset { min-width: 0; }
|
||||
.entry-recent > td { animation: saved-booking 6s ease-out; }
|
||||
@keyframes saved-booking {
|
||||
from { background-color: var(--color-surface-hover); box-shadow: inset 0 2px var(--color-accent), inset 0 -2px var(--color-accent); }
|
||||
to { background-color: transparent; box-shadow: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); }
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
|
||||
{
|
||||
services.AddSingleton(new DatabaseLocation(connectionString));
|
||||
services.AddDbContext<FinanceDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
// The factory also registers FinanceDbContext as scoped. Registering
|
||||
// AddDbContext first would give its singleton factory scoped options.
|
||||
services.AddDbContextFactory<FinanceDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
@@ -24,6 +24,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IFileSaveService, FileSaveService>();
|
||||
services.AddSingleton<IBackupService, BackupService>();
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<ReportingPeriod>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
||||
{
|
||||
if (!Enum.IsDefined(type)) throw new ArgumentException("Ungültige Buchungsart.");
|
||||
BookingInput.Validate(date, title, amount);
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -110,7 +112,7 @@ public class EntryService : IEntryService
|
||||
DisplayId = displayId,
|
||||
Type = type,
|
||||
Date = date,
|
||||
Title = title,
|
||||
Title = title.Trim(),
|
||||
Amount = amount
|
||||
};
|
||||
db.Entries.Add(entry);
|
||||
@@ -119,8 +121,9 @@ public class EntryService : IEntryService
|
||||
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
|
||||
}
|
||||
|
||||
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||
{
|
||||
BookingInput.Validate(date, title, amount);
|
||||
if (sourceAccountId == targetAccountId)
|
||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||
|
||||
@@ -136,7 +139,7 @@ public class EntryService : IEntryService
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Expense,
|
||||
Date = date,
|
||||
Title = title,
|
||||
Title = title.Trim(),
|
||||
Amount = amount
|
||||
};
|
||||
|
||||
@@ -149,7 +152,7 @@ public class EntryService : IEntryService
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Income,
|
||||
Date = date,
|
||||
Title = title,
|
||||
Title = title.Trim(),
|
||||
Amount = amount
|
||||
};
|
||||
|
||||
@@ -170,6 +173,7 @@ public class EntryService : IEntryService
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return sourceEntry.Id;
|
||||
}
|
||||
|
||||
public async Task DeleteEntryAsync(int entryId)
|
||||
@@ -220,6 +224,7 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
|
||||
{
|
||||
BookingInput.Validate(date, title, amount);
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -230,7 +235,7 @@ public class EntryService : IEntryService
|
||||
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
|
||||
|
||||
entry.Date = date;
|
||||
entry.Title = title;
|
||||
entry.Title = title.Trim();
|
||||
entry.Amount = amount;
|
||||
|
||||
if (link != null)
|
||||
@@ -240,7 +245,7 @@ public class EntryService : IEntryService
|
||||
if (otherEntry != null)
|
||||
{
|
||||
otherEntry.Date = date;
|
||||
otherEntry.Title = title;
|
||||
otherEntry.Title = title.Trim();
|
||||
otherEntry.Amount = amount;
|
||||
}
|
||||
}
|
||||
@@ -250,6 +255,7 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
|
||||
{
|
||||
BookingInput.Validate(date, title, amount);
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -266,10 +272,10 @@ public class EntryService : IEntryService
|
||||
|
||||
// Update date, title, amount on both sides
|
||||
entry.Date = date;
|
||||
entry.Title = title;
|
||||
entry.Title = title.Trim();
|
||||
entry.Amount = amount;
|
||||
otherEntry.Date = date;
|
||||
otherEntry.Title = title;
|
||||
otherEntry.Title = title.Trim();
|
||||
otherEntry.Amount = amount;
|
||||
|
||||
// If the linked account has changed, move the other entry to the new account
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingFormTests
|
||||
{
|
||||
[Fact]
|
||||
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
|
||||
Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
|
||||
Assert.Contains("Bitte beschreiben", dialog.Markup);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-title").Input("Porto");
|
||||
dialog.Find("#entry-amount").Input("12,501");
|
||||
Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value"));
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("-12,50");
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-date").Input("");
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
|
||||
dialog.Find("#entry-date").Input("2026-01-02");
|
||||
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
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 dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
|
||||
Assert.Contains("Ausgabe", dialog.Markup);
|
||||
Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup);
|
||||
dialog.Find("#entry-title").Input("Korrigierter Text");
|
||||
dialog.Render(p => p.Add(x => x.EditEntry, entry));
|
||||
Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingInputTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("12,50", true)]
|
||||
[InlineData("12.50", true)]
|
||||
[InlineData(" 12,50 ", true)]
|
||||
[InlineData("12,500", false)]
|
||||
[InlineData("-12,50", false)]
|
||||
[InlineData("1.234,56", false)]
|
||||
[InlineData("1,234.56", false)]
|
||||
[InlineData("12,50 €", false)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("9999999999999999999999999999999999999", false)]
|
||||
public void DecimalInput_HasExplicitRules(string text, bool valid)
|
||||
{
|
||||
Assert.Equal(valid, BookingInput.TryAmount(text, out var amount));
|
||||
if (valid) Assert.Equal(12.50m, amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceValidation_RejectsInvalidData()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(default, "Test", 1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, " ", 1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", -1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m));
|
||||
Assert.Equal(default, BookingInput.Date("2026-02-30"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingSaveTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RepeatedSubmission_StoresOnce_ThenAllowsDeliberateNewBooking(bool transfer)
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var page = fixture.Context.Render<Routes>();
|
||||
page.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
OpenAndFill(page, transfer);
|
||||
var delayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fixture.Entries.BeforeWrite = () => delayed.Task;
|
||||
var pending = page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
page.WaitForAssertion(() => Assert.Contains("Wird gespeichert", page.Markup));
|
||||
Assert.True(page.Find("button[type=submit]").HasAttribute("disabled"));
|
||||
// Directly deliver repeated submit events, including ones a disabled UI would suppress.
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Equal(1, fixture.Entries.WriteCalls);
|
||||
delayed.SetResult();
|
||||
await pending;
|
||||
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".alert-success").TextContent));
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
Assert.Single(page.FindAll("tr.entry-recent"));
|
||||
if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null));
|
||||
|
||||
fixture.Entries.BeforeWrite = () => Task.CompletedTask;
|
||||
OpenAndFill(page, transfer);
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
page.WaitForAssertion(() => Assert.Empty(page.FindAll("form")));
|
||||
Assert.Equal(2, (await fixture.Entries.GetEntriesAsync(1, (int?)null)).Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Failure_PreservesInputs_AndRetryWorks(bool transfer)
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var page = fixture.Context.Render<Routes>();
|
||||
page.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
OpenAndFill(page, transfer);
|
||||
fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler"));
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Contains("Eingaben bleiben erhalten", page.Markup);
|
||||
Assert.Empty(page.FindAll(".alert-success"));
|
||||
Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value"));
|
||||
Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value"));
|
||||
Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
fixture.Entries.BeforeWrite = () => Task.CompletedTask;
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Markup));
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationFailureAfterCommit_RetriesRefreshWithoutWritingAgain()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var notifications = 0;
|
||||
var dialog = fixture.Context.Render<AddEntryDialog>(p => p
|
||||
.Add(x => x.AccountId, 1)
|
||||
.Add(x => x.OnSave, (BookingSaved _) =>
|
||||
{
|
||||
if (++notifications == 1) throw new IOException("Refresh fehlgeschlagen");
|
||||
}));
|
||||
dialog.Find("#entry-title").Input("Testbuchung");
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Contains("Die Buchung ist gespeichert", dialog.Markup);
|
||||
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Equal(1, fixture.Entries.WriteCalls);
|
||||
Assert.Equal(2, notifications);
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
}
|
||||
|
||||
private static void OpenAndFill(IRenderedComponent<Routes> page, bool transfer)
|
||||
{
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains(transfer ? "Geld zwischen Konten" : "Einnahme oder Ausgabe")).Click();
|
||||
if (transfer) page.Find("#transfer-account").Change("2");
|
||||
page.Find(transfer ? "#transfer-title" : "#entry-title").Input("Testbuchung");
|
||||
page.Find(transfer ? "#transfer-amount" : "#entry-amount").Input("12,50");
|
||||
}
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public BunitContext Context { get; } = new();
|
||||
public ControlledEntries Entries { get; }
|
||||
private readonly SqliteConnection anchor;
|
||||
public Fixture()
|
||||
{
|
||||
var connection = $"Data Source=save-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
anchor = new SqliteConnection(connection);
|
||||
anchor.Open();
|
||||
Context.Services.AddInfrastructure(connection);
|
||||
Context.Services.AddSingleton<IEntryService>(sp => new ControlledEntries(new EntryService(sp.GetRequiredService<IDbContextFactory<FinanceDbContext>>())));
|
||||
Context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" });
|
||||
db.SaveChanges();
|
||||
Entries = (ControlledEntries)Context.Services.GetRequiredService<IEntryService>();
|
||||
}
|
||||
public void Dispose() { Context.Dispose(); anchor.Dispose(); }
|
||||
}
|
||||
|
||||
private sealed class ControlledEntries(IEntryService inner) : IEntryService
|
||||
{
|
||||
public int WriteCalls;
|
||||
public Func<Task> BeforeWrite = () => Task.CompletedTask;
|
||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
||||
{ WriteCalls++; await BeforeWrite(); return await inner.CreateEntryAsync(accountId, type, date, title, amount); }
|
||||
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||
{ WriteCalls++; await BeforeWrite(); return await inner.CreateTransferAsync(sourceAccountId, targetAccountId, date, title, amount); }
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year);
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly);
|
||||
public Task<List<int>> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId);
|
||||
public Task<List<int>> GetAllEntryYearsAsync() => inner.GetAllEntryYearsAsync();
|
||||
public Task DeleteEntryAsync(int entryId) => inner.DeleteEntryAsync(entryId);
|
||||
public Task RestoreEntryAsync(int entryId) => inner.RestoreEntryAsync(entryId);
|
||||
public Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) => inner.UpdateEntryAsync(entryId, date, title, amount);
|
||||
public Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) => inner.UpdateTransferAsync(entryId, newLinkedAccountId, date, title, amount);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" Version="2.13.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.16" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ReportingPeriodTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyYearList_PreservesSelectedYear_AndOffersCurrentYear()
|
||||
{
|
||||
var period = new ReportingPeriod { Year = 2025 };
|
||||
Assert.Contains(2025, period.Choices([]));
|
||||
Assert.Contains(DateTime.Today.Year, period.Choices([]));
|
||||
Assert.Equal("Jahr: 2025", period.Label);
|
||||
period.Year = null;
|
||||
Assert.Equal("Alle Jahre", period.Label);
|
||||
Assert.Null(period.Year);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class YearNavigationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Navigation_PreservesYear_AndOutsideBookingOffersReturn()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
var connectionString = $"Data Source=year-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
using var anchor = new SqliteConnection(connectionString);
|
||||
anchor.Open();
|
||||
context.Services.AddInfrastructure(connectionString);
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using (var db = context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
db.Accounts.Add(new() { Name = "Testkasse" });
|
||||
db.SaveChanges();
|
||||
await context.Services.GetRequiredService<IEntryService>().CreateEntryAsync(db.Accounts.Single().Id,
|
||||
EntryType.Income, new(2025, 1, 1), "Vorjahr", 10m);
|
||||
}
|
||||
context.Services.GetRequiredService<ReportingPeriod>().Year = 2025;
|
||||
var page = context.Render<Routes>();
|
||||
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
|
||||
var dialog = page.FindComponent<AddEntryDialog>();
|
||||
dialog.Find("input[type=date]").Input("2026-01-02");
|
||||
dialog.Find("#entry-title").Input("Außerhalb");
|
||||
dialog.Find("#entry-amount").Input("12.50");
|
||||
Assert.Contains("außerhalb", dialog.Markup);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("input[type=checkbox]").Change(true);
|
||||
dialog.Find("form").Submit();
|
||||
page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
|
||||
Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
|
||||
Assert.Equal(2026, context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Jahr: 2026")).Click();
|
||||
page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click();
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user