diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/AccessCodeForm.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/AccessCodeForm.razor index 1113b905..a966327f 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/AccessCodeForm.razor +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/AccessCodeForm.razor @@ -1,56 +1,168 @@ -@* DUMB COMPONENT: Kennt keine Services, nur Parameter und Events *@ +@* AccessCodeForm: Zeigt das Zugangscode-Eingabeformular. + Entspricht dem AccessCode-Teil von EnvelopeLocked.cshtml im Web-Projekt. + + "Dumme" Komponente: Keine Services, keine API-Calls. + - Bekommt Daten über [Parameter] + - Gibt Eingaben über EventCallback an die Eltern-Page zurück + - Die Page entscheidet, was mit dem Code passiert *@ -
-

Zugangscode eingeben

-

Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.

- - - - -
- - +
+ @* ── Header: Icon + Titel ── *@ +
+
+
+

Zugangscode eingeben

+
- @if (!string.IsNullOrEmpty(ErrorMessage)) - { -
@ErrorMessage
- } + @* ── Erklärungstext ── *@ +
+

+ Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet. + Bitte geben Sie diesen unten ein. +

+
- - + + @* Submit-Button mit Loading-State *@ + + +
+ + @* ── Hilfe-Bereich: Wer hat dieses Dokument gesendet? ── + Im Web-Projekt ist das der
-Bereich ganz unten. + Zeigt Sender-Mail und Umschlag-Titel, damit der Empfänger + bei Problemen weiß, an wen er sich wenden kann. *@ + @if (!string.IsNullOrEmpty(SenderEmail)) + { +
+
+ Woher kommt dieser Code? +

+ Dieses Dokument + @if (!string.IsNullOrEmpty(Title)) + { + «@Title» + } + wurde Ihnen von @SenderEmail zugesendet. + Der Zugangscode wurde ebenfalls an Ihre E-Mail-Adresse geschickt. +

+
+
+ }
@code { - // Parameter von der Eltern-Page - [Parameter] public required string EnvelopeKey { get; set; } - [Parameter] public string? ErrorMessage { get; set; } + // ── Parameter von der Eltern-Page ── - // EventCallback: Informiert die Page, dass ein Code eingegeben wurde - [Parameter] public EventCallback OnSubmit { get; set; } + /// Der Envelope-Key aus der URL + [Parameter, EditorRequired] + public string EnvelopeKey { get; set; } = string.Empty; + + /// Fehlermeldung (z.B. "Falscher Zugangscode") + [Parameter] + public string? ErrorMessage { get; set; } + + /// E-Mail des Absenders — für den Hilfe-Bereich unten + [Parameter] + public string? SenderEmail { get; set; } + + /// Titel des Umschlags — für den Hilfe-Bereich unten + [Parameter] + public string? Title { get; set; } + + /// Ob TFA für diesen Umschlag aktiviert ist — zeigt den SMS-Switch + [Parameter] + public bool TfaEnabled { get; set; } + + /// Ob der Empfänger eine Telefonnummer hat — sonst ist SMS-Switch disabled + [Parameter] + public bool HasPhoneNumber { get; set; } + + // ── Event: Gibt Code + SMS-Präferenz an die Page zurück ── + + /// + /// Callback wenn der Benutzer das Formular abschickt. + /// Gibt ein Tuple zurück: (Code, PreferSms) + /// Die Page entscheidet dann, was damit passiert (API-Call etc.) + /// + /// WARUM ein Tuple statt nur string? + /// Weil die Page auch wissen muss, ob der Benutzer SMS bevorzugt. + /// Im Web-Projekt wird das als separates Form-Feld "userSelectSMS" gesendet. + /// + [Parameter] + public EventCallback<(string Code, bool PreferSms)> OnSubmit { get; set; } + + // ── Interner State ── private AccessCodeModel _model = new(); private bool _isSubmitting; + private bool _preferSms; + + private void ToggleSms(ChangeEventArgs e) + { + _preferSms = (bool)(e.Value ?? false); + } private async Task Submit() { _isSubmitting = true; - await OnSubmit.InvokeAsync(_model.Code); + await OnSubmit.InvokeAsync((_model.Code, _preferSms)); _isSubmitting = false; } + // ── Validierungs-Model ── + private class AccessCodeModel { [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Zugangscode eingeben")] diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/ActionPanel.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/ActionPanel.razor new file mode 100644 index 00000000..eb9b5ba8 --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/ActionPanel.razor @@ -0,0 +1,126 @@ +@* ActionPanel: Fixierte Button-Leiste am unteren Bildschirmrand. + Entspricht dem
in ShowEnvelope.cshtml. + + Wird angezeigt wenn der Empfänger das Dokument sieht (Status = ShowDocument). + Bei ReadOnly-Dokumenten wird nichts angezeigt. + + Die Buttons lösen EventCallbacks aus. Die Eltern-Komponente (EnvelopePage) + entscheidet, was passiert (API-Call etc.). + + Der ConfirmDialog wird über eine Referenz aufgerufen: + var confirmed = await _confirmDialog.ShowAsync("Titel", "Text"); + + WARUM EventCallbacks statt direkte API-Calls? + - Die Komponente bleibt "dumm" (keine Services, kein API-Wissen) + - Die Eltern-Page kann den gleichen Button-Click anders behandeln + - Einfacher zu testen *@ + +@if (!ReadOnly) +{ + @* position-fixed: Bleibt am unteren Rand auch beim Scrollen. + Gleiche Positionierung wie im Web-Projekt. *@ +
+ + @* Zurücksetzen-Button: Setzt alle Signaturen zurück. + Im Web-Projekt ist das der graue Button mit dem + Pfeil-gegen-den-Uhrzeigersinn-Icon. *@ + + + @* Ablehnen-Button: Öffnet ConfirmDialog, dann EventCallback. + Im Web-Projekt ist das der rote Button mit dem X-Icon. *@ + + + @* Unterschreiben-Button: Öffnet ConfirmDialog, dann EventCallback. + Im Web-Projekt ist das der grüne Button mit dem Briefumschlag-Icon. *@ + +
+} + +@* ConfirmDialog: Wird nur gerendert wenn nötig (wenn ShowAsync aufgerufen wird). + Die Referenz (_confirmDialog) erlaubt uns, ShowAsync von den Button-Handlern aufzurufen. *@ + + +@code { + // ── Parameter ── + + /// Bei ReadOnly wird das gesamte Panel ausgeblendet + [Parameter] + public bool ReadOnly { get; set; } + + /// Wird ausgelöst wenn der Benutzer "Unterschreiben" bestätigt + [Parameter] + public EventCallback OnSign { get; set; } + + /// Wird ausgelöst wenn der Benutzer "Ablehnen" bestätigt + [Parameter] + public EventCallback OnReject { get; set; } + + /// Wird ausgelöst wenn der Benutzer "Zurücksetzen" klickt + [Parameter] + public EventCallback OnRefresh { get; set; } + + // ── Referenz auf den ConfirmDialog ── + + /// + /// Referenz auf die ConfirmDialog-Komponente. + /// + /// WAS IST @ref? + /// Mit @ref speichert Blazor eine Referenz auf eine Kind-Komponente. + /// Dann kann man Methoden darauf aufrufen: _confirmDialog.ShowAsync(...) + /// Das ist wie document.getElementById() in JavaScript, nur typsicher. + /// + private ConfirmDialog _confirmDialog = default!; + + // ── Button-Handler ── + + /// + /// Unterschreiben: Erst bestätigen, dann Event auslösen. + /// Der ConfirmDialog zeigt "Möchten Sie das Dokument unterschreiben?" + /// Nur wenn der Benutzer "Ja" klickt, wird OnSign ausgelöst. + /// + private async Task HandleSign() + { + var confirmed = await _confirmDialog.ShowAsync( + "Unterschreiben", + "Möchten Sie das Dokument wirklich unterschreiben? Diese Aktion kann nicht rückgängig gemacht werden.", + confirmText: "Unterschreiben", + confirmColor: "success"); + + if (confirmed) + await OnSign.InvokeAsync(); + } + + /// + /// Ablehnen: Erst bestätigen, dann Event auslösen. + /// Roter Button im ConfirmDialog, weil Ablehnen destruktiv ist. + /// + private async Task HandleReject() + { + var confirmed = await _confirmDialog.ShowAsync( + "Ablehnen", + "Möchten Sie das Dokument wirklich ablehnen? Diese Aktion kann nicht rückgängig gemacht werden.", + confirmText: "Ablehnen", + confirmColor: "danger"); + + if (confirmed) + await OnReject.InvokeAsync(); + } + + /// + /// Zurücksetzen: Kein ConfirmDialog nötig, weil es nicht destruktiv ist. + /// Setzt nur die Signatur-Positionen zurück, der Empfänger kann danach + /// erneut signieren. + /// + private async Task HandleRefresh() + { + await OnRefresh.InvokeAsync(); + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/EnvelopeInfoCard.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/EnvelopeInfoCard.razor new file mode 100644 index 00000000..9a35d9cc --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/EnvelopeInfoCard.razor @@ -0,0 +1,121 @@ +@* EnvelopeInfoCard: Zeigt die Umschlag-Infos oberhalb des PDF-Viewers. + Entspricht dem Card-Bereich in ShowEnvelope.cshtml im Web-Projekt. + + Wird angezeigt wenn der Empfänger erfolgreich authentifiziert ist + und das Dokument sehen darf. + + "Dumme" Komponente: Keine Services, nur Parameter → Anzeige. *@ + +
+ @* ── Card Header: Logo + Fortschrittsbalken ── + Im Web-Projekt gibt es hier das signFLOW-Logo und darunter + "2/3 Signatures". Wir zeigen den Fortschritt nur wenn es + KEIN ReadOnly-Dokument ist (ReadOnly = nur lesen, nicht signieren). *@ +
+
+ signFLOW + + @if (!ReadOnly && SignatureTotal > 0) + { +
+
+
+
+
+ @SignaturesDone/@SignatureTotal +
+ } + + @if (ReadOnly) + { + Nur Ansicht + } +
+
+ + @* ── Card Body: Titel, Nachricht, Sender-Info ── *@ +
+ @* Titel des Umschlags *@ +
@Title
+ + @* Nachricht des Absenders — im Web-Projekt wird das mit Marked.js + als Markdown gerendert. Hier erstmal als Plain Text. + Markdown-Rendering kommt in Phase 6 (Feinschliff). *@ + @if (!string.IsNullOrEmpty(Message)) + { +

@Message

+ } + + @* Sender-Info: Wer hat es gesendet, wann? + Im Web-Projekt steht hier: + "Gesendet am 15.03.2026 von Max Mustermann (max@firma.de)" *@ +

+ + @if (!string.IsNullOrEmpty(SenderName) && !string.IsNullOrEmpty(SenderEmail)) + { + + Gesendet + @if (SentDate is not null) + { + am @SentDate.Value.ToString("dd.MM.yyyy") + } + von @SenderName + (@SenderEmail) + + } + +

+
+
+ +@code { + // ── Parameter ── + + /// Titel des Umschlags (z.B. "Vertragsdokument") + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// Nachricht des Absenders + [Parameter] + public string? Message { get; set; } + + /// Name des Absenders (z.B. "Max Mustermann") + [Parameter] + public string? SenderName { get; set; } + + /// E-Mail des Absenders + [Parameter] + public string? SenderEmail { get; set; } + + /// Datum an dem der Umschlag gesendet wurde + [Parameter] + public DateTime? SentDate { get; set; } + + /// Ob das Dokument nur zum Lesen ist (kein Signieren) + [Parameter] + public bool ReadOnly { get; set; } + + /// Anzahl bereits geleisteter Unterschriften + [Parameter] + public int SignaturesDone { get; set; } + + /// Gesamtanzahl benötigter Unterschriften + [Parameter] + public int SignatureTotal { get; set; } + + // ── Berechnete Werte ── + + /// + /// Fortschritt in Prozent für den Balken. + /// Vermeidet Division durch Null. + /// + private int ProgressPercent => + SignatureTotal > 0 + ? (int)((double)SignaturesDone / SignatureTotal * 100) + : 0; +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/TfaForm.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/TfaForm.razor new file mode 100644 index 00000000..67bb0c55 --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Envelope/TfaForm.razor @@ -0,0 +1,202 @@ +@* TfaForm: Zwei-Faktor-Authentifizierung — SMS oder Authenticator. + Entspricht dem TFA-Teil von EnvelopeLocked.cshtml im Web-Projekt. + + Wird angezeigt NACH dem AccessCode-Schritt, wenn der Umschlag TFA erfordert. + + Zwei Varianten: + - TfaType="sms" → SMS wurde gesendet, Countdown-Timer zeigt verbleibende Zeit + - TfaType="authenticator" → Benutzer gibt Code aus seiner Authenticator-App ein + + "Dumme" Komponente: Keine Services, keine API-Calls. *@ + +@implements IDisposable + +
+ @* ── Header: Icon + Titel ── *@ +
+
+ +
+

Zwei-Faktor-Authentifizierung

+
+ + @* ── Erklärungstext: Unterschiedlich je nach TFA-Typ ── *@ +
+ @if (TfaType == "sms") + { +

+ Ein Bestätigungscode wurde per SMS an Ihre Telefonnummer gesendet. +

+ @* SMS-Countdown-Timer: Zeigt wie lange der Code noch gültig ist. + Im Web-Projekt ist das JavaScript (setInterval). + Hier machen wir es mit einem C#-Timer — kein JS nötig. *@ + @if (_remainingTime is not null) + { +
+ @if (_remainingTime > TimeSpan.Zero) + { + Code gültig für: @_remainingTime.Value.ToString("mm\\:ss") + } + else + { + Code abgelaufen. Bitte fordern Sie einen neuen Code an. + } +
+ } + } + else + { +

+ Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten Code ein. +

+ } +
+ + @* ── Formular ── *@ +
+ + + +
+ + + +
+ + @if (!string.IsNullOrEmpty(ErrorMessage)) + { +
@ErrorMessage
+ } + + +
+
+
+ +@code { + // ── Parameter von der Eltern-Page ── + + /// Der Envelope-Key aus der URL + [Parameter, EditorRequired] + public string EnvelopeKey { get; set; } = string.Empty; + + /// + /// "sms" oder "authenticator" — bestimmt welche Variante angezeigt wird. + /// Kommt aus der API-Antwort nach dem AccessCode-Schritt. + /// + [Parameter, EditorRequired] + public string TfaType { get; set; } = "authenticator"; + + /// + /// Ablaufzeit des SMS-Codes. Nur bei TfaType="sms" relevant. + /// Der Timer zählt von jetzt bis zu diesem Zeitpunkt runter. + /// + [Parameter] + public DateTime? TfaExpiration { get; set; } + + /// Ob der Empfänger eine Telefonnummer hat + [Parameter] + public bool HasPhoneNumber { get; set; } + + /// Fehlermeldung (z.B. "Falscher Code") + [Parameter] + public string? ErrorMessage { get; set; } + + /// + /// Callback: Gibt (Code, Type) an die Page zurück. + /// Die Page sendet das dann an die API. + /// + [Parameter] + public EventCallback<(string Code, string Type)> OnSubmit { get; set; } + + // ── Interner State ── + + private TfaCodeModel _model = new(); + private bool _isSubmitting; + private TimeSpan? _remainingTime; + private System.Threading.Timer? _timer; + + // ── Lifecycle ── + + /// + /// Wird aufgerufen wenn die Komponente initialisiert wird. + /// Startet den SMS-Countdown-Timer falls nötig. + /// + /// OnInitialized (nicht Async) reicht hier, weil wir keinen + /// await brauchen — der Timer läuft über einen Callback. + /// + protected override void OnInitialized() + { + if (TfaType == "sms" && TfaExpiration is not null) + { + // Restzeit berechnen + _remainingTime = TfaExpiration.Value - DateTime.Now; + + // Timer: Jede Sekunde _remainingTime aktualisieren. + // InvokeAsync + StateHasChanged sagt Blazor: "Zeichne die UI neu". + // Das ist das Blazor-Äquivalent zu setInterval + DOM-Update in JavaScript. + _timer = new System.Threading.Timer(_ => + { + _remainingTime = TfaExpiration.Value - DateTime.Now; + + if (_remainingTime <= TimeSpan.Zero) + { + _remainingTime = TimeSpan.Zero; + _timer?.Dispose(); + } + + // InvokeAsync ist nötig, weil der Timer auf einem anderen Thread läuft. + // Blazor erlaubt UI-Updates nur auf dem UI-Thread. + InvokeAsync(StateHasChanged); + }, + state: null, + dueTime: TimeSpan.Zero, // Sofort starten + period: TimeSpan.FromSeconds(1) // Jede Sekunde + ); + } + } + + private async Task Submit() + { + _isSubmitting = true; + await OnSubmit.InvokeAsync((_model.Code, TfaType)); + _isSubmitting = false; + } + + /// + /// Aufräumen: Timer stoppen wenn die Komponente entfernt wird. + /// Ohne Dispose würde der Timer weiterlaufen und Fehler verursachen, + /// weil er versucht eine nicht mehr existierende UI zu aktualisieren. + /// + public void Dispose() + { + _timer?.Dispose(); + } + + // ── Validierungs-Model ── + + private class TfaCodeModel + { + [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Code eingeben")] + [System.ComponentModel.DataAnnotations.StringLength(6, MinimumLength = 6, ErrorMessage = "Der Code muss 6 Zeichen lang sein")] + public string Code { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/ConfirmDialog.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/ConfirmDialog.razor new file mode 100644 index 00000000..d1892b1c --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/ConfirmDialog.razor @@ -0,0 +1,133 @@ +@* ConfirmDialog: Ersetzt SweetAlert2 aus dem Web-Projekt. + + Zeigt einen modalen Bestätigungsdialog mit Titel, Text und Ja/Nein-Buttons. + Wird NICHT über Parameter gesteuert, sondern über eine Methode: + + var confirmed = await _dialog.ShowAsync("Titel", "Text"); + + WARUM eine Methode statt Parameter? + - Ein Dialog ist ein "einmaliges Ereignis", kein dauerhafter Zustand + - Die aufrufende Komponente will auf das Ergebnis WARTEN (await) + - Mit Parametern müsste man den State manuell hin- und herschalten + + WARUM kein SweetAlert2? + - SweetAlert2 ist eine JavaScript-Bibliothek + - In Blazor können wir das nativ in C# lösen, ohne JS-Interop + - Weniger Abhängigkeiten = weniger Wartung = weniger Fehlerquellen *@ + +@if (_isVisible) +{ + @* Hintergrund-Overlay: Dunkler Hintergrund hinter dem Dialog. + Im Web-Projekt macht SweetAlert2 das automatisch. + Hier bauen wir es selbst mit CSS. *@ + + + @* Modal-Dialog: Bootstrap 5 Modal-Markup. + Normalerweise braucht Bootstrap-Modal JavaScript (bootstrap.js) + um zu öffnen/schließen. Wir steuern die Sichtbarkeit stattdessen + über die _isVisible-Variable — Blazor macht das DOM-Update. *@ + +} + +@code { + // ── Interner State (NICHT Parameter — wird über ShowAsync gesetzt) ── + + private bool _isVisible; + private string _title = string.Empty; + private string _message = string.Empty; + private string _confirmText = "Ja"; + private string _cancelText = "Abbrechen"; + private string _confirmColor = "primary"; + + /// + /// TaskCompletionSource: Das Herzstück dieser Komponente. + /// + /// WAS IST DAS? + /// Ein TaskCompletionSource erstellt einen Task, der erst dann + /// "fertig" wird, wenn jemand SetResult() aufruft. + /// + /// WIE FUNKTIONIERT ES? + /// 1. ShowAsync() erstellt ein neues TaskCompletionSource + /// 2. ShowAsync() gibt dessen Task zurück → der Aufrufer wartet (await) + /// 3. Der Benutzer klickt "Ja" → Confirm() ruft SetResult(true) auf + /// 4. Der await in der aufrufenden Komponente kommt zurück mit true + /// + /// Das ist wie ein "Promise" in JavaScript, nur in C#. + /// + private TaskCompletionSource? _tcs; + + /// + /// Zeigt den Dialog und wartet auf die Benutzer-Entscheidung. + /// + /// Beispiel-Aufruf: + /// var confirmed = await _dialog.ShowAsync( + /// "Unterschreiben", + /// "Möchten Sie das Dokument wirklich unterschreiben?"); + /// + /// Dialog-Überschrift + /// Beschreibungstext + /// Text auf dem Bestätigen-Button (Standard: "Ja") + /// Text auf dem Abbrechen-Button (Standard: "Abbrechen") + /// Bootstrap-Farbe des Bestätigen-Buttons (Standard: "primary") + /// true wenn bestätigt, false wenn abgebrochen + public Task ShowAsync( + string title, + string message, + string confirmText = "Ja", + string cancelText = "Abbrechen", + string confirmColor = "primary") + { + _title = title; + _message = message; + _confirmText = confirmText; + _cancelText = cancelText; + _confirmColor = confirmColor; + + _tcs = new TaskCompletionSource(); + _isVisible = true; + StateHasChanged(); + + return _tcs.Task; + } + + private void Confirm() + { + _isVisible = false; + _tcs?.TrySetResult(true); + StateHasChanged(); + } + + private void Cancel() + { + _isVisible = false; + _tcs?.TrySetResult(false); + StateHasChanged(); + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/StatusPage.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/StatusPage.razor new file mode 100644 index 00000000..091167e7 --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/StatusPage.razor @@ -0,0 +1,79 @@ +@* StatusPage: Wiederverwendbare Status-Seite für alle Endzustände. + Ersetzt EnvelopeSigned.cshtml, EnvelopeRejected.cshtml, Error-Views und Expired. + "Dumme" Komponente: Keine Services, keine API-Calls, nur Parameter → Anzeige. *@ + +
+
+ @switch (Type) + { + case "signed": +
+ +
+

Dokument erfolgreich unterschrieben

+

+ Sie erhalten eine Bestätigung per E-Mail, sobald alle Empfänger unterschrieben haben. +

+ break; + + case "rejected": +
+ +
+

Dokument wurde abgelehnt

+

+ @if (!string.IsNullOrEmpty(Title) && !string.IsNullOrEmpty(SenderEmail)) + { + + Das Dokument «@Title» wurde abgelehnt. + Bei Fragen wenden Sie sich an + @SenderEmail. + + } + else + { + Dieses Dokument wurde von einem Empfänger abgelehnt. + } +

+ break; + + case "not_found": +
+ +
+

Dokument nicht gefunden

+

+ Dieses Dokument existiert nicht oder ist nicht mehr verfügbar. + Wenn Sie diese URL per E-Mail erhalten haben, wenden Sie sich bitte an das IT-Team. +

+ break; + + case "expired": +
+ +
+

Link abgelaufen

+

+ Der Zugang zu diesem Dokument ist abgelaufen. +

+ break; + } +
+
+ +@code { + /// + /// Bestimmt welche Status-Variante angezeigt wird. + /// Erlaubte Werte: "signed", "rejected", "not_found", "expired" + /// + [Parameter, EditorRequired] + public string Type { get; set; } = string.Empty; + + /// E-Mail des Absenders — nur bei "rejected" relevant. + [Parameter] + public string? SenderEmail { get; set; } + + /// Titel des Umschlags — nur bei "rejected" relevant. + [Parameter] + public string? Title { get; set; } +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/Toast.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/Toast.razor new file mode 100644 index 00000000..d67475ea --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Components/Shared/Toast.razor @@ -0,0 +1,70 @@ +@* Toast: Zeigt kurze Benachrichtigungen an. Ersetzt AlertifyJS aus dem Web-Projekt. + + Wird einmal im MainLayout platziert. Hört auf den ToastService. + Wenn eine Komponente irgendwo _toast.ShowSuccess("Text") aufruft, + erscheint hier ein Toast und verschwindet nach 4 Sekunden automatisch. + + Bootstrap Toast-Klassen werden genutzt: + - toast-container: Positionierung (oben rechts) + - toast: Einzelner Toast + - bg-success/bg-danger/etc: Farbe basierend auf Typ *@ + +@inject ToastService ToastService +@implements IDisposable + +@if (ToastService.Messages.Count > 0) +{ + @* toast-container: Bootstrap-Klasse die Toasts oben rechts positioniert. + position-fixed: Bleibt an der Stelle auch beim Scrollen. + z-index 1080: Über dem Modal-Backdrop (1070) damit Toasts + auch während eines ConfirmDialogs sichtbar sind. *@ +
+ @foreach (var message in ToastService.Messages) + { + + } +
+} + +@code { + protected override void OnInitialized() + { + // Subscriber: Wenn der ToastService eine Änderung meldet, + // zeichnet diese Komponente sich neu (StateHasChanged). + // So erscheinen/verschwinden Toasts automatisch. + ToastService.OnChange += HandleChange; + } + + /// + /// InvokeAsync ist nötig weil OnChange von einem + /// async void (dem Timer im ToastService) ausgelöst wird. + /// Das kann auf einem anderen Thread passieren. + /// InvokeAsync wechselt zurück auf den Blazor UI-Thread. + /// + private async void HandleChange() + { + await InvokeAsync(StateHasChanged); + } + + /// + /// Event-Handler abmelden wenn die Komponente entfernt wird. + /// Ohne das würde der ToastService eine Referenz auf eine + /// nicht mehr existierende Komponente halten → Memory Leak. + /// + public void Dispose() + { + ToastService.OnChange -= HandleChange; + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs index fc688bf6..ee4d8011 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs @@ -24,4 +24,6 @@ builder.Services.AddScoped(); // State: Ein State-Objekt pro Browser-Tab builder.Services.AddScoped(); +builder.Services.AddScoped(); + await builder.Build().RunAsync(); \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ToastService.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ToastService.cs new file mode 100644 index 00000000..2aead5ee --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ToastService.cs @@ -0,0 +1,86 @@ +namespace EnvelopeGenerator.ReceiverUI.Client.Services; + +/// +/// Service für Toast-Benachrichtigungen. Ersetzt AlertifyJS aus dem Web-Projekt. +/// +/// WARUM ein Service und keine Komponente mit Parametern? +/// - Toasts können von ÜBERALL ausgelöst werden (Pages, Services, andere Komponenten) +/// - Ein Service ist über Dependency Injection überall verfügbar +/// - Die Toast-Komponente im Layout hört auf diesen Service und rendert die Nachrichten +/// +/// PATTERN: Pub/Sub (Publisher/Subscriber) +/// - Publisher: Jede Komponente die _toast.ShowSuccess("...") aufruft +/// - Subscriber: Die Toast-Komponente im MainLayout, die auf OnChange hört +/// +/// Das ist das gleiche Pattern wie beim EnvelopeState. +/// +public class ToastService +{ + /// + /// Liste aller aktuell sichtbaren Toasts. + /// Mehrere Toasts können gleichzeitig angezeigt werden (gestapelt). + /// + public List Messages { get; } = []; + + /// Event: Informiert die Toast-Komponente über Änderungen + public event Action? OnChange; + + public void ShowSuccess(string text) => Show(text, "success"); + public void ShowError(string text) => Show(text, "danger"); + public void ShowInfo(string text) => Show(text, "info"); + public void ShowWarning(string text) => Show(text, "warning"); + + /// + /// Fügt einen Toast hinzu und entfernt ihn nach der angegebenen Dauer automatisch. + /// + /// WARUM async void? + /// Normalerweise vermeidet man async void. Hier ist es ok, weil: + /// - Es ist ein Fire-and-Forget-Timer (wir warten nicht auf das Ergebnis) + /// - Fehler im Delay können die App nicht zum Absturz bringen + /// - Das ist ein gängiges Pattern für Auto-Dismiss-Logik + /// + private async void Show(string text, string type, int durationMs = 4000) + { + var message = new ToastMessage(text, type); + Messages.Add(message); + OnChange?.Invoke(); + + // Nach Ablauf der Dauer automatisch entfernen + await Task.Delay(durationMs); + Messages.Remove(message); + OnChange?.Invoke(); + } + + /// Entfernt einen Toast sofort (z.B. wenn der Benutzer auf X klickt) + public void Dismiss(ToastMessage message) + { + Messages.Remove(message); + OnChange?.Invoke(); + } +} + +/// +/// Ein einzelner Toast-Eintrag. +/// +/// WARUM ein record statt class? +/// - Records haben automatisch Equals/GetHashCode basierend auf allen Properties +/// - Wir brauchen das für Messages.Remove() — es vergleicht über Referenz-Gleichheit +/// - Die Id (Guid) macht jeden Toast einzigartig, auch bei gleichem Text +/// +public record ToastMessage(string Text, string Type) +{ + /// Eindeutige Id — damit zwei Toasts mit gleichem Text unterscheidbar sind + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// Gibt die Bootstrap-Icon-Klasse basierend auf dem Typ zurück. + /// success → check-circle, danger → x-circle, etc. + /// + public string IconClass => Type switch + { + "success" => "bi-check-circle-fill", + "danger" => "bi-x-circle-fill", + "warning" => "bi-exclamation-triangle-fill", + _ => "bi-info-circle-fill" + }; +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/Components/Layout/MainLayout.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/Components/Layout/MainLayout.razor index e28f2bfb..ec279200 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/Components/Layout/MainLayout.razor +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/Components/Layout/MainLayout.razor @@ -1,12 +1,35 @@ -@inherits LayoutComponentBase +@* MainLayout: Das Grundgerüst jeder Seite. + Entspricht _Layout.cshtml im Web-Projekt. + + Aufbau: + - Header: signFLOW-Logo/Titel (oben, sticky) + - Main: Der Seiteninhalt (@Body) mit ErrorBoundary + - Footer: Copyright + Privacy-Link (unten) + + Sticky Footer Pattern: Der Footer klebt immer am unteren Rand, + auch wenn der Inhalt wenig Platz braucht. Das funktioniert über + Flexbox in app.css (.app-container mit min-height: 100vh). *@ + +@inherits LayoutComponentBase
+ + + @* ── Header ── *@
-
+
+ @* Im Web-Projekt steht hier ein mit dem signFLOW-Logo. + Wir nutzen erstmal Text. Das Logo kommt in Phase 6 + wenn wir die Bilder aus dem Web-Projekt portieren. *@ signFLOW
+ @* ── Main: Seiteninhalt mit Error-Schutz ── + ErrorBoundary fängt unbehandelte Exceptions in Komponenten ab. + Ohne ErrorBoundary würde die gesamte App abstürzen. + Mit ErrorBoundary zeigen wir stattdessen eine Fehlermeldung + und einen "Erneut versuchen"-Button. *@
@@ -14,21 +37,51 @@
-

😵 Ein unerwarteter Fehler ist aufgetreten

+
+ +
+

Ein unerwarteter Fehler ist aufgetreten

Bitte versuchen Sie es erneut.

- +
-
- © @DateTime.Now.Year Digital Data GmbH + @* ── Footer ── + Im Web-Projekt gibt es hier drei Elemente: + 1. Copyright + Link zur Firmenwebsite + 2. Sprachauswahl (Dropdown mit Flaggen) → kommt in Phase 6 + 3. Privacy-Link (Datenschutzerklärung) + + Die Datenschutz-HTML-Dateien existieren im Web-Projekt unter + wwwroot/privacy-policy.de-DE.html. Wir verlinken vorerst + auf eine statische URL. Die Datei selbst portieren wir in Phase 6. *@ +
@code { private ErrorBoundary? _errorBoundary; + /// + /// Setzt die ErrorBoundary zurück. + /// Blazor rendert dann @Body erneut statt der Fehlermeldung. + /// private void Recover() => _errorBoundary?.Recover(); } \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/wwwroot/app.css b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/wwwroot/app.css index 71303821..9b660a06 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/wwwroot/app.css +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI/wwwroot/app.css @@ -8,14 +8,14 @@ Wenn sich das Branding ändert, änderst du nur diese Werte. */ :root { --sf-primary: #0d6efd; - --sf - danger: #dc3545; - --sf - success: #198754; - --sf - bg: #f8f9fa; - --sf - text: #212529; - --sf - muted: #6c757d; - --sf - border: #dee2e6; - --sf - header - height: 56px; ---sf - footer - height: 48px; + --sf-danger: #dc3545; + --sf-success: #198754; + --sf-bg: #f8f9fa; + --sf-text: #212529; + --sf-muted: #6c757d; + --sf-border: #dee2e6; + --sf-header-height: 56px; + --sf-footer-height: 48px; } /* ----- Globale Resets ----- @@ -23,67 +23,67 @@ *, *::before, *::after { - box - sizing: border - box; + box-sizing: border-box; } html, body { - height: 100 %; -margin: 0; -font - family: 'Segoe UI', system - ui, -apple - system, sans - serif; -background - color: var(--sf - bg); -color: var(--sf - text); + height: 100%; + margin: 0; + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background-color: var(--sf-bg); + color: var(--sf-text); } /* ----- App-Container ----- WARUM: Flexbox-Layout damit Footer immer unten bleibt, auch wenn der Content wenig Inhalt hat (Sticky Footer Pattern). */ -.app - container { -display: flex; - flex - direction: column; - min - height: 100vh; +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; } /* ----- Header ----- WARUM: Fester Header oben wie im Web-Projekt. Höhe ist als CSS-Variable definiert, damit main darunter beginnt. */ -.app - header { - background - color: #fff; - border - bottom: 1px solid var(--sf - border); -height: var(--sf - header - height); -display: flex; - align - items: center; -padding: 0 1rem; -position: sticky; -top: 0; - z - index: 1000; +.app-header { + background-color: #fff; + border-bottom: 1px solid var(--sf-border); + height: var(--sf-header-height); + display: flex; + align-items: center; + padding: 0 1rem; + position: sticky; + top: 0; + z-index: 1000; } -.app - title { - font - weight: 700; - font - size: 1.25rem; -color: var(--sf - primary); +.app-title { + font-weight: 700; + font-size: 1.25rem; + color: var(--sf-primary); } /* ----- Main Content ----- WARUM: flex: 1 sorgt dafür, dass der Content-Bereich den gesamten verfügbaren Platz einnimmt. Der Footer wird nach unten gedrückt. */ -.app - main { -flex: 1; -padding: 1rem; +.app-main { + flex: 1; + padding: 1rem; } /* ----- Footer ----- WARUM: Immer am unteren Rand. Enthält Copyright + Sprachauswahl + Privacy-Link (wie im Web-Projekt). */ -.app - footer { - background - color: #fff; - border - top: 1px solid var(--sf - border); -height: var(--sf - footer - height); -display: flex; - align - items: center; - justify - content: center; -gap: 1rem; -padding: 0 1rem; +.app-footer { + background-color: #fff; + border-top: 1px solid var(--sf-border); + height: var(--sf-footer-height); + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 0 1rem; } /* ----- Page Container ----- @@ -91,72 +91,80 @@ padding: 0 1rem; Entspricht dem
im Web-Projekt. */ .page { max-width: 600px; -margin: 0 auto; -padding: 2rem 1rem; + margin: 0 auto; + padding: 2rem 1rem; } /* ----- AccessCode-Formular ----- WARUM: Zentriertes Eingabefeld wie EnvelopeLocked.cshtml. Die Klasse .code-input macht das Eingabefeld größer und zentriert den Text. */ -.access - code - container { - text - align: center; +.access-code-container { + text-align: center; } -.code - input { - text - align: center; - font - size: 1.5rem; - letter - spacing: 0.5rem; - max - width: 300px; -margin: 0 auto; +.code-input { + text-align: center; + font-size: 1.5rem; + letter-spacing: 0.5rem; + max-width: 300px; + margin: 0 auto; } /* ----- Status-Icons ----- WARUM: Die SVG-Icons für Signed/Rejected/Locked aus dem Web-Projekt bekommen hier einheitliche Größen und Farben. */ -.status - icon { -display: flex; - justify - content: center; - margin - bottom: 1rem; +.status-icon { + display: flex; + justify-content: center; + margin-bottom: 1rem; } -.status - icon svg, -.status-icon .bi { - font-size: 4rem; -} + .status-icon svg, + .status-icon .bi { + font-size: 4rem; + } -.status - icon.signed { color: var(--sf - success); } -.status - icon.rejected { color: var(--sf - danger); } -.status - icon.locked { color: var(--sf - muted); } + .status-icon.signed { + color: var(--sf-success); + } + + .status-icon.rejected { + color: var(--sf-danger); + } + + .status-icon.locked { + color: var(--sf-muted); + } /* ----- PDF-Container ----- WARUM: PSPDFKit braucht einen Container mit fester Höhe. Wird in Phase 6 relevant, aber die Klasse wird schon jetzt definiert. */ -.pdf - container { -border: 1px solid var(--sf - border); - border - radius: 4px; -overflow: hidden; +.pdf-container { + border: 1px solid var(--sf-border); + border-radius: 4px; + overflow: hidden; } /* ----- Error Container ----- WARUM: Styling für die ErrorBoundary im MainLayout. */ -.error - container { - max - width: 500px; -margin: 0 auto; +.error-container { + max-width: 500px; + margin: 0 auto; } /* ----- Responsive ----- WARUM: Auf Mobilgeräten braucht der Content weniger Padding. */ -@media(max - width: 576px) { - .app - main { - padding: 0.5rem; +@media (max-width: 576px) { + .app-main { + padding: 0.5rem; } .page { - padding: 1rem 0.5rem; + padding: 1rem 0.5rem; } - .code - input { - font - size: 1.25rem; - letter - spacing: 0.3rem; + .code-input { + font-size: 1.25rem; + letter-spacing: 0.3rem; } -} \ No newline at end of file +}