feat(ui): constrain decimal input and add stacked timed toasts with uniform actions

This commit is contained in:
2026-11-02 19:19:07 +01:00
parent 22e509d982
commit 86a73b862a
24 changed files with 315 additions and 103 deletions
@@ -0,0 +1,24 @@
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
aria-describedby="@(Id + "-rejected")" />
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
@code {
[Parameter] public string Id { get; set; } = "amount";
[Parameter] public string Value { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public bool AllowNegative { get; set; }
private string? rejection;
private async Task SetValue(string value)
{
if (!BookingInput.IsAmountDraft(value, AllowNegative))
{
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
return;
}
rejection = null;
await ValueChanged.InvokeAsync(value);
}
}
@@ -1,3 +1,4 @@
@inject ToastService Toasts
@inject IEntryService EntryService
<div class="dialog-backdrop">
@@ -36,8 +37,8 @@
<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>
<AmountInput Id="entry-amount" @bind-Value="amountText" />
<p class="text-muted">Betrag ohne Minuszeichen.</p>
</div>
</div>
@@ -50,13 +51,6 @@
}
</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")
@@ -73,6 +73,7 @@ public partial class AddEntryDialog
private async Task Save()
{
if (isSaving || completed || (committedResult == null && !CanSave)) return;
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
isSaving = true;
saveError = null;
saveDetails = null;
@@ -98,8 +99,9 @@ public partial class AddEntryDialog
? "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;
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
}
finally { isSaving = false; }
finally { isSaving = false; Toasts.Dismiss(busyToast); }
}
private async Task Cancel()
@@ -1,3 +1,4 @@
@inject ToastService Toasts
@inject IEntryService EntryService
@inject IAccountService AccountService
@@ -26,8 +27,8 @@
<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><AmountInput Id="transfer-amount" @bind-Value="amountText" />
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
</div>
@if (OutsideYear)
@@ -39,13 +40,6 @@
}
</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")
@@ -81,6 +81,7 @@ public partial class AddTransferDialog
private async Task Save()
{
if (isSaving || completed || (committedResult == null && !CanSave)) return;
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
isSaving = true;
saveError = null;
saveDetails = null;
@@ -106,8 +107,9 @@ public partial class AddTransferDialog
? "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;
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
}
finally { isSaving = false; }
finally { isSaving = false; Toasts.Dismiss(busyToast); }
}
private async Task Cancel()
@@ -1,5 +1,7 @@
@inherits LayoutComponentBase
<ToastHost />
<div class="d-flex flex-column vh-100">
<nav class="app-navbar d-flex justify-content-between align-items-center">
<a class="navbar-brand" href="">
@@ -1,40 +1,19 @@
@inject ToastService Toasts
@inject IFileSaveService FileSaveService
@if (Result != null)
{
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
<p>@Result.Message</p>
@if (Result.Path != null)
{
<p class="text-break">@Result.Path</p>
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
}
@if (Result.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
}
@if (openResult != null)
{
<p role="alert">@openResult.Message</p>
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
}
</div>
}
@code {
[Parameter] public FileOperationResult? Result { get; set; }
private FileOperationResult? openResult;
private FileOperationResult? previous;
private bool busy;
protected override void OnParametersSet()
{
if (previous != Result) { openResult = null; previous = Result; }
}
private async Task OpenFolder()
{
if (busy || Result?.Path == null) return;
busy = true;
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
finally { busy = false; }
if (Result == null || ReferenceEquals(previous, Result)) return;
previous = Result;
var result = Result;
Toasts.Show(result.Message + (result.Path != null ? " " + result.Path : ""),
result.Status == FileOperationStatus.Error ? ToastKind.Error : result.Status == FileOperationStatus.Success ? ToastKind.Success : ToastKind.Info,
result.Details, result.Path != null ? "Ordner öffnen" : null,
result.Path != null ? async () => {
var opened = await FileSaveService.OpenFolderAsync(result.Path);
Toasts.Show(opened.Message, opened.Status == FileOperationStatus.Error ? ToastKind.Error : ToastKind.Info, opened.Details);
} : null);
}
}
@@ -0,0 +1,38 @@
@implements IDisposable
@inject ToastService Toasts
<div class="toast-stack" aria-label="Meldungen">
@foreach (var toast in Toasts.Messages)
{
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
role="@(toast.Kind == ToastKind.Error ? "alert" : "status")" aria-live="@(toast.Kind == ToastKind.Error ? "assertive" : "polite")" aria-atomic="true"
@onmouseenter="() => Toasts.Pause(toast.Id)" @onmouseleave="() => Toasts.Resume(toast.Id)"
@onfocusin="() => Toasts.Pause(toast.Id)" @onfocusout="() => Toasts.Resume(toast.Id)">
<button type="button" class="toast-close" aria-label="Meldung schließen" @onclick="() => Toasts.Dismiss(toast.Id)">×</button>
<p>@toast.Message</p>
@if (toast.Action != null)
{
<button type="button" class="btn btn-outline-secondary" @onclick="() => RunAction(toast)">@toast.ActionLabel</button>
}
@if (toast.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@toast.Details</p></details>
}
</section>
}
</div>
@code {
private readonly HashSet<Guid> running = [];
protected override void OnInitialized() => Toasts.Changed += Refresh;
private void Refresh() => _ = InvokeAsync(StateHasChanged);
private async Task RunAction(ToastMessage toast)
{
if (!running.Add(toast.Id)) return;
Toasts.Pause(toast.Id);
try { await toast.Action!(); }
catch (Exception ex) { Toasts.Show("Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { running.Remove(toast.Id); Toasts.Resume(toast.Id); }
}
public void Dispose() => Toasts.Changed -= Refresh;
}