feat(usability): explain booking fields and validate decimal input (U06)
This commit is contained in:
@@ -35,3 +35,7 @@ Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück
|
|||||||
## U05 – Alltagssprache
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,30 +1,45 @@
|
|||||||
@inject IEntryService EntryService
|
@inject IEntryService EntryService
|
||||||
|
|
||||||
<div class="dialog-backdrop">
|
<div class="dialog-backdrop">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
|
||||||
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
|
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
|
||||||
|
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
|
<label class="form-label" for="entry-type">Art</label>
|
||||||
@if (EditEntry == null)
|
@if (EditEntry == null)
|
||||||
{
|
{
|
||||||
<label class="form-label">Art</label>
|
<select id="entry-type" @bind="entryType">
|
||||||
<select @bind="entryType">
|
|
||||||
<option value="@EntryType.Income">Einnahme</option>
|
<option value="@EntryType.Income">Einnahme</option>
|
||||||
<option value="@EntryType.Expense">Ausgabe</option>
|
<option value="@EntryType.Expense">Ausgabe</option>
|
||||||
</select>
|
</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">Datum</label>
|
<label class="form-label" for="entry-date">Datum</label>
|
||||||
<input type="date" @bind="date" />
|
<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">Bezeichnung</label>
|
<label class="form-label" for="entry-title">Beschreibung</label>
|
||||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
|
<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">Betrag (€)</label>
|
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (OutsideYear)
|
@if (OutsideYear && date != default)
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning" role="status">
|
<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.
|
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||||
@@ -33,12 +48,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<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"
|
<button class="btn btn-success" @onclick="Save" disabled="@(!CanSave)">
|
||||||
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0 || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))">
|
<i class="bi bi-check-lg"></i> Speichern
|
||||||
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,20 @@ public partial class AddEntryDialog
|
|||||||
private int? confirmedYear;
|
private int? confirmedYear;
|
||||||
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
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));
|
||||||
|
|
||||||
#region Fields
|
#region Fields
|
||||||
|
|
||||||
private EntryType entryType = EntryType.Income;
|
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 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
|
#endregion
|
||||||
|
|
||||||
@@ -40,12 +48,15 @@ public partial class AddEntryDialog
|
|||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
|
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||||
|
initialized = true;
|
||||||
|
initializedEntryId = EditEntry?.Id;
|
||||||
if (EditEntry != null)
|
if (EditEntry != null)
|
||||||
{
|
{
|
||||||
entryType = EditEntry.Type;
|
entryType = EditEntry.Type;
|
||||||
date = EditEntry.Date;
|
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||||
title = EditEntry.Title;
|
title = EditEntry.Title;
|
||||||
amount = EditEntry.Amount;
|
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +66,7 @@ public partial class AddEntryDialog
|
|||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
|
if (!CanSave) return;
|
||||||
if (EditEntry != null)
|
if (EditEntry != null)
|
||||||
{
|
{
|
||||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@inject IAccountService AccountService
|
@inject IAccountService AccountService
|
||||||
|
|
||||||
<div class="dialog-backdrop">
|
<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>
|
<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>
|
||||||
@@ -18,13 +18,14 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="form-label">Datum</label>
|
<label class="form-label">Datum</label>
|
||||||
<input type="date" class="form-control" @bind="date" />
|
<input 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">Bezeichnung</label>
|
||||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" />
|
<input type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||||
|
|
||||||
<label class="form-label">Betrag (€)</label>
|
<label class="form-label">Betrag (€)</label>
|
||||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
<div><input 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>
|
</div>
|
||||||
|
|
||||||
@if (OutsideYear)
|
@if (OutsideYear)
|
||||||
|
|||||||
@@ -30,15 +30,20 @@ public partial class AddTransferDialog
|
|||||||
|
|
||||||
private List<AccountSummaryDto> accounts = new();
|
private List<AccountSummaryDto> accounts = new();
|
||||||
private int targetAccountId;
|
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 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
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#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
|
#endregion
|
||||||
|
|
||||||
@@ -51,12 +56,15 @@ public partial class AddTransferDialog
|
|||||||
accounts = await AccountService.GetAllAccountsAsync();
|
accounts = await AccountService.GetAllAccountsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||||
|
initialized = true;
|
||||||
|
initializedEntryId = EditEntry?.Id;
|
||||||
if (EditEntry != null)
|
if (EditEntry != null)
|
||||||
{
|
{
|
||||||
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
||||||
date = EditEntry.Date;
|
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||||
title = EditEntry.Title;
|
title = EditEntry.Title;
|
||||||
amount = EditEntry.Amount;
|
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ public partial class AddTransferDialog
|
|||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
|
if (!CanSave) return;
|
||||||
if (EditEntry != null)
|
if (EditEntry != null)
|
||||||
{
|
{
|
||||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
@if (showAddEntry)
|
@if (showAddEntry)
|
||||||
{
|
{
|
||||||
<AddEntryDialog AccountId="AccountId" ContextYear="selectedYear"
|
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||||
OnSave="HandleEntryCreated"
|
OnSave="HandleEntryCreated"
|
||||||
OnCancel="() => showAddEntry = false" />
|
OnCancel="() => showAddEntry = false" />
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
|
|
||||||
@if (editingEntry != null)
|
@if (editingEntry != null)
|
||||||
{
|
{
|
||||||
<AddEntryDialog AccountId="AccountId" ContextYear="selectedYear"
|
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||||
EditEntry="editingEntry"
|
EditEntry="editingEntry"
|
||||||
OnSave="HandleEntryEdited"
|
OnSave="HandleEntryEdited"
|
||||||
OnCancel="() => editingEntry = null" />
|
OnCancel="() => editingEntry = null" />
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -597,3 +597,8 @@ input[type="number"].no-spinner {
|
|||||||
|
|
||||||
.more-actions { align-self: center; }
|
.more-actions { align-self: center; }
|
||||||
.more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; }
|
.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; }
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
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);
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ public class EntryService : IEntryService
|
|||||||
DisplayId = displayId,
|
DisplayId = displayId,
|
||||||
Type = type,
|
Type = type,
|
||||||
Date = date,
|
Date = date,
|
||||||
Title = title,
|
Title = title.Trim(),
|
||||||
Amount = amount
|
Amount = amount
|
||||||
};
|
};
|
||||||
db.Entries.Add(entry);
|
db.Entries.Add(entry);
|
||||||
@@ -121,6 +123,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||||
{
|
{
|
||||||
|
BookingInput.Validate(date, title, amount);
|
||||||
if (sourceAccountId == targetAccountId)
|
if (sourceAccountId == targetAccountId)
|
||||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ public class EntryService : IEntryService
|
|||||||
DisplayId = transferDisplayId,
|
DisplayId = transferDisplayId,
|
||||||
Type = EntryType.Expense,
|
Type = EntryType.Expense,
|
||||||
Date = date,
|
Date = date,
|
||||||
Title = title,
|
Title = title.Trim(),
|
||||||
Amount = amount
|
Amount = amount
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +152,7 @@ public class EntryService : IEntryService
|
|||||||
DisplayId = transferDisplayId,
|
DisplayId = transferDisplayId,
|
||||||
Type = EntryType.Income,
|
Type = EntryType.Income,
|
||||||
Date = date,
|
Date = date,
|
||||||
Title = title,
|
Title = title.Trim(),
|
||||||
Amount = amount
|
Amount = amount
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,6 +223,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
|
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
|
||||||
{
|
{
|
||||||
|
BookingInput.Validate(date, title, amount);
|
||||||
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();
|
||||||
|
|
||||||
@@ -230,7 +234,7 @@ public class EntryService : IEntryService
|
|||||||
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
|
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
|
||||||
|
|
||||||
entry.Date = date;
|
entry.Date = date;
|
||||||
entry.Title = title;
|
entry.Title = title.Trim();
|
||||||
entry.Amount = amount;
|
entry.Amount = amount;
|
||||||
|
|
||||||
if (link != null)
|
if (link != null)
|
||||||
@@ -240,7 +244,7 @@ public class EntryService : IEntryService
|
|||||||
if (otherEntry != null)
|
if (otherEntry != null)
|
||||||
{
|
{
|
||||||
otherEntry.Date = date;
|
otherEntry.Date = date;
|
||||||
otherEntry.Title = title;
|
otherEntry.Title = title.Trim();
|
||||||
otherEntry.Amount = amount;
|
otherEntry.Amount = amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +254,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
|
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);
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
@@ -266,10 +271,10 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
// Update date, title, amount on both sides
|
// Update date, title, amount on both sides
|
||||||
entry.Date = date;
|
entry.Date = date;
|
||||||
entry.Title = title;
|
entry.Title = title.Trim();
|
||||||
entry.Amount = amount;
|
entry.Amount = amount;
|
||||||
otherEntry.Date = date;
|
otherEntry.Date = date;
|
||||||
otherEntry.Title = title;
|
otherEntry.Title = title.Trim();
|
||||||
otherEntry.Amount = amount;
|
otherEntry.Amount = amount;
|
||||||
|
|
||||||
// If the linked account has changed, move the other entry to the new account
|
// 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,9 +42,9 @@ public class YearNavigationTests
|
|||||||
page.WaitForElement(".summary-section");
|
page.WaitForElement(".summary-section");
|
||||||
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
|
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
|
||||||
var dialog = page.FindComponent<AddEntryDialog>();
|
var dialog = page.FindComponent<AddEntryDialog>();
|
||||||
dialog.Find("input[type=date]").Change("2026-01-02");
|
dialog.Find("input[type=date]").Input("2026-01-02");
|
||||||
dialog.Find("input[type=text]").Change("Außerhalb");
|
dialog.Find("#entry-title").Input("Außerhalb");
|
||||||
dialog.Find("input[type=number]").Change("12.50");
|
dialog.Find("#entry-amount").Input("12.50");
|
||||||
Assert.Contains("außerhalb", dialog.Markup);
|
Assert.Contains("außerhalb", dialog.Markup);
|
||||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||||
dialog.Find("input[type=checkbox]").Change(true);
|
dialog.Find("input[type=checkbox]").Change(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user