@page "/sender" @rendermode InteractiveWebAssembly @using System.Text.Json @using EnvelopeGenerator.Domain.Constants @using EnvelopeGenerator.Server.Client.Models @using DevExpress.Blazor @using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Application.Common.Dto @using EnvelopeGenerator.Application.Common.Dto.History @using EnvelopeGenerator.Application.Resources @using EnvelopeGenerator.Domain.Resources @using Microsoft.Extensions.Localization @inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService @inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService @inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService @inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService @inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService @inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService @inject ILogger Logger @inject NavigationManager Navigation @inject IJSRuntime JSRuntime @inject AppVersionService AppVersion @inject IStringLocalizer ModelLocalizer @inject IStringLocalizer UiLocalizer
signFLOW Portal Übersicht
@* ── Kunden-Logo (Mitte) ───────────────────────────────────────── *@
@if (_companyLogo is not null && !string.IsNullOrEmpty(_companyLogo.Src)) { Kunden-Logo } else {
Kunden-Logo
}
@* ── Toolbar (rechts) ─────────────────────────────────────────── *@
@if (_isLoading && _allEnvelopes == null) {
@L("Loading", "Lädt...")

@L("Sender_LoadingEnvelopes", "Umschläge werden geladen...")

} else if (_errorMessage != null) {
@L("Sender_LoadError_Title", "Fehler beim Laden der Umschläge")

@_errorMessage

} else {
@((cellContext.DataItem as EnvelopeDto)?.Id) @((cellContext.DataItem as EnvelopeDto)?.Title) @{ var envelope = cellContext.DataItem as EnvelopeDto; if (envelope != null) { var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
@statusInfo.Label
} }
@{ var envelope = cellContext.DataItem as EnvelopeDto; if (envelope != null) { var receivers = envelope.EnvelopeReceivers?.ToList() ?? []; var signed = GetSignedReceiverCount(envelope); var total = receivers.Count;
@signed / @total @L("Signed", "unterschrieben") @if (total > 0) {
}
} }
@L("Receiver", "Empfänger")
@{ var envelope = detailContext.DataItem as EnvelopeDto; if (envelope?.EnvelopeReceivers?.Any() == true) {
@foreach (var receiver in envelope.EnvelopeReceivers) {
@{ var isRejected = IsReceiverRejected(envelope, receiver); var isSigned = IsReceiverSigned(envelope, receiver); var accessCode = receiver.AccessCode; var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode); } @if (isRejected) { @GetStatusLabel(EnvelopeStatus.DocumentRejected) } else if (isSigned) { @L("Signed", "Unterschrieben") } else { @L("Pending", "Ausstehend") }
@receiver.Name @receiver.Receiver?.EmailAddress
@L("AccessCode", "Access code")
}
} else {

@L("Sender_NoReceivers", "Keine Empfänger")

} }
@L("History", "Historie")
@{ var historyEntries = envelope is null ? Enumerable.Empty() : GetOrderedHistories(envelope); if (historyEntries.Any()) {
@foreach (var history in historyEntries) { var statusInfo = GetStatusInfo(history.Status);
@statusInfo.Label
@GetHistoryActor(history)
@GetHistoryDate(history).ToString("dd.MM.yyyy HH:mm")
}
} else {

@L("Sender_NoHistory", "Keine Historie vorhanden")

} }
}
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@ @if (_previewLoading) {
@L("Loading", "Lädt...")

@L("DocumentLoading", "Dokument wird geladen…")

} else if (_previewErrorMessage is not null) {

@L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden")

@_previewErrorMessage

} else if (_previewPdfBytes is not null && _previewViewerMounted) {
}

@L("Sender_DeleteEnvelope_ReasonPrompt", "Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.")

@if (_selectedEnvelope is not null) {

@L("Envelope", "Umschlag"): #@_selectedEnvelope.Id - @_selectedEnvelope.Title

} @if (!string.IsNullOrWhiteSpace(_deleteErrorMessage)) {
@_deleteErrorMessage
}
@code { private IEnumerable? _allEnvelopes; private IEnumerable? _activeEnvelopes; private IEnumerable? _completedEnvelopes; private EnvelopeDto? _selectedEnvelope; private string _activeTab = "active"; private bool _isLoading = true; private bool _isLoggingOut = false; private string? _errorMessage; private DxGrid? _grid; // Logo config loaded from /api/CustomImages after auth private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo; private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _companyLogo; // Preview popup private bool _previewVisible = false; private bool _previewLoading = false; private bool _previewViewerMounted = false; private byte[]? _previewPdfBytes = null; private int _previewPageCount = 0; private List _previewPagePreviews = []; private string _previewFileName = "document.pdf"; private string? _previewErrorMessage = null; private readonly IReadOnlyList _previewFieldOverlays = []; private string? PreviewPdfDataUrl => _previewPdfBytes is { Length: > 0 } ? $"data:application/pdf;base64,{Convert.ToBase64String(_previewPdfBytes)}" : null; // Phase 6e (step 1): UI state only. Export logic is added in step 2. private string _selectedExportFormat = "xlsx"; private bool _exportSelectedRowsOnly = false; private bool _isExporting = false; private bool _deletePopupVisible = false; private bool _isDeletingEnvelope = false; private string _deleteReason = string.Empty; private string? _deleteErrorMessage; // Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts private const string GridKeyActive = "sender.active-envelopes"; private const string GridKeyCompleted = "sender.completed-envelopes"; private IEnumerable CurrentGridData => (_activeTab == "completed" ? _completedEnvelopes : _activeEnvelopes) ?? Enumerable.Empty(); private string CurrentGridKey => _activeTab == "completed" ? GridKeyCompleted : GridKeyActive; protected override async Task OnInitializedAsync() { var hasAccess = await AuthService.CheckSenderAccessAsync(); if (!hasAccess) { Navigation.NavigateTo($"/sender/login"); return; } // Load theme + DisplayName from server after authentication. // Non-fatal: if this fails the page still loads with default theme. // ThemeService.ScheduleSaveAsync() already logs errors internally. try { await ThemeService.LoadFromServerAsync(); } catch { /* swallow — default theme remains active */ } // Load logo config — authenticated, so this should succeed var logos = await CustomImagesService.GetAsync(); _appLogo = logos.App; _companyLogo = logos.Company; await LoadEnvelopesAsync(); } async Task LoadEnvelopesAsync() { _isLoading = true; _errorMessage = null; await InvokeAsync(StateHasChanged); try { _allEnvelopes = await EnvelopeService.GetAsync() ?? []; // Split into active and completed based on effective status // (history-aware: rejected envelopes should not stay in active tab). var envelopes = _allEnvelopes.ToList(); _activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList(); _completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList(); } catch (Exception ex) { _errorMessage = ex.Message; } finally { _isLoading = false; await InvokeAsync(StateHasChanged); } } async Task RefreshEnvelopes() { await LoadEnvelopesAsync(); } bool IsExportDisabled() { if (_isLoading || _isExporting) return true; return _exportSelectedRowsOnly && _selectedEnvelope is null; } async Task ExportSelectedFormatAsync() { var grid = _grid; if (grid is null) return; _isExporting = true; await InvokeAsync(StateHasChanged); try { var tabToken = _activeTab == "completed" ? "abgeschlossen" : "aktiv"; var stamp = DateTime.Now.ToString("yyyy-MM-dd"); var exportBaseName = $"Umschlaege_{tabToken}_{stamp}"; switch (_selectedExportFormat) { case "csv": await grid.ExportToCsvAsync( exportBaseName, new GridCsvExportOptions { ExportSelectedRowsOnly = _exportSelectedRowsOnly }); break; case "pdf": await grid.ExportToPdfAsync( exportBaseName, new GridPdfExportOptions { ExportSelectedRowsOnly = _exportSelectedRowsOnly, SelectedRowsExportMode = GridSelectedRowsExportMode.KeepGrouping }); break; default: await grid.ExportToXlsxAsync( exportBaseName, new GridXlExportOptions { ExportSelectedRowsOnly = _exportSelectedRowsOnly, SelectedRowsExportMode = GridSelectedRowsExportMode.KeepGrouping }); break; } } finally { _isExporting = false; await InvokeAsync(StateHasChanged); } } void CreateEnvelope() { Navigation.NavigateTo("/sender/editor"); } void EditEnvelope() { if (_selectedEnvelope == null) return; if (!IsEnvelopeEditable(_selectedEnvelope)) return; Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}"); } void DeleteEnvelope() { if (_selectedEnvelope == null) return; _deleteReason = string.Empty; _deleteErrorMessage = null; _deletePopupVisible = true; } void CloseDeletePopup() { if (_isDeletingEnvelope) return; _deletePopupVisible = false; _deleteErrorMessage = null; } async Task ConfirmDeleteEnvelopeAsync() { if (_selectedEnvelope is null) return; if (string.IsNullOrWhiteSpace(_deleteReason)) { _deleteErrorMessage = L("Sender_DeleteEnvelope_ReasonRequired", "Bitte geben Sie einen Löschgrund an."); return; } _isDeletingEnvelope = true; _deleteErrorMessage = null; await InvokeAsync(StateHasChanged); try { await EnvelopeService.DeleteAsync(_selectedEnvelope.Id, _deleteReason.Trim()); _deletePopupVisible = false; _selectedEnvelope = null; await LoadEnvelopesAsync(); } catch (Exception ex) { _deleteErrorMessage = ex.Message; } finally { _isDeletingEnvelope = false; await InvokeAsync(StateHasChanged); } } async Task LogoutAsync() { _isLoggingOut = true; await InvokeAsync(StateHasChanged); UserPreferencesService.InvalidateCache(); await AuthService.LogoutSenderAsync(); Navigation.NavigateTo("/sender/login", forceLoad: false); } bool IsEnvelopeSent(EnvelopeDto envelope) { var status = GetEffectiveEnvelopeStatus(envelope); return status >= EnvelopeStatus.EnvelopeQueued; } bool IsEnvelopeEditable(EnvelopeDto envelope) { var status = GetEffectiveEnvelopeStatus(envelope); return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved; } (string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status) { var label = GetStatusLabel(status); return status switch { EnvelopeStatus.EnvelopePartlySigned => (label, "partly-signed", "green"), EnvelopeStatus.EnvelopeCompletelySigned => (label, "completed", "green"), EnvelopeStatus.DocumentSigned => (label, "partly-signed", "green"), EnvelopeStatus.MessageCompletionSent => (label, "completed", "green"), EnvelopeStatus.EnvelopeQueued => (label, "queued", "orange"), EnvelopeStatus.EnvelopeSent => (label, "sent", "orange"), EnvelopeStatus.MessageInvitationSent => (label, "queued", "orange"), EnvelopeStatus.MessageAccessCodeSent => (label, "queued", "orange"), EnvelopeStatus.MessageConfirmationSent => (label, "queued", "orange"), EnvelopeStatus.EnvelopeDeleted => (label, "deleted", "red"), EnvelopeStatus.EnvelopeRejected => (label, "rejected", "red"), EnvelopeStatus.EnvelopeWithdrawn => (label, "withdrawn", "red"), EnvelopeStatus.DocumentRejected => (label, "rejected", "red"), EnvelopeStatus.AccessCodeIncorrect => (label, "rejected", "red"), EnvelopeStatus.MessageDeletionSent => (label, "deleted", "red"), _ => (label, "created", "blue") }; } string GetStatusLabel(EnvelopeStatus status) { var key = status.ToString(); var localized = ModelLocalizer[key]; return localized.ResourceNotFound ? key : localized.Value; } IEnumerable GetOrderedHistories(EnvelopeDto envelope) { return (envelope.Histories ?? Enumerable.Empty()) .OrderByDescending(GetHistoryDate) .Take(20); } IReadOnlyList GetStatusFilterItems(IEnumerable? source) { return (source ?? Enumerable.Empty()) .Select(GetEffectiveEnvelopeStatus) .Distinct() .OrderBy(x => x) .Select(x => new StatusFilterItem(x, GetStatusLabel(x))) .ToList(); } int GetSignedReceiverCount(EnvelopeDto envelope) { var receivers = envelope.EnvelopeReceivers?.ToList() ?? []; if (receivers.Count == 0) return 0; return receivers.Count(r => IsReceiverSigned(envelope, r)); } bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver) { var status = GetEffectiveEnvelopeStatus(envelope); if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent) return true; var histories = envelope.Histories?.Where(h => h.Status == EnvelopeStatus.DocumentSigned).ToList() ?? []; if (histories.Count == 0) return false; if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId)) return true; var receiverEmail = receiver.Receiver?.EmailAddress; if (!string.IsNullOrWhiteSpace(receiverEmail) && histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase))) return true; if (!string.IsNullOrWhiteSpace(receiver.Name) && histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase))) return true; return false; } bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver) { var status = GetEffectiveEnvelopeStatus(envelope); var histories = envelope.Histories?.Where(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? []; if (histories.Count == 0) return false; if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId)) return true; var receiverEmail = receiver.Receiver?.EmailAddress; if (!string.IsNullOrWhiteSpace(receiverEmail) && histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase))) return true; if (!string.IsNullOrWhiteSpace(receiver.Name) && histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase))) return true; return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1; } EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope) { var rawStatus = envelope.Status; if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn) return rawStatus; var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty()) .Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected); return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus; } DateTime GetHistoryDate(HistoryDto history) { return history.ActionDate ?? history.AddedWhen; } string GetHistoryActor(HistoryDto history) { if (!string.IsNullOrWhiteSpace(history.UserReference)) return history.UserReference; return L("System", "System"); } string L(string key, string fallback) { var value = UiLocalizer[key]; return value.ResourceNotFound ? fallback : value.Value; } async Task CopyAccessCodeAsync(string? accessCode) { if (string.IsNullOrWhiteSpace(accessCode)) return; try { await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", accessCode); } catch { await JSRuntime.InvokeVoidAsync("console.warn", "[Sender] Failed to copy access code to clipboard."); } } void OnCustomizeElement(GridCustomizeElementEventArgs e) { // Future: Add custom row coloring based on status if needed } // Single click: native DX selection handles the UI instantly. // We only manage toggle: if the same row is clicked again, deselect. void OnRowClick(GridRowClickEventArgs e) { var clicked = e.Grid.GetDataItem(e.VisibleIndex) as EnvelopeDto; if (clicked == null) return; if (_selectedEnvelope?.Id == clicked.Id) { // Same row clicked again → deselect _selectedEnvelope = null; e.Grid.DeselectDataItem(clicked); } // Different row: @bind-SelectedDataItem already updated _selectedEnvelope } // Double-click: select the row and open preview popup void OnRowDoubleClick(GridRowClickEventArgs e) { var clicked = e.Grid.GetDataItem(e.VisibleIndex) as EnvelopeDto; if (clicked == null) return; _selectedEnvelope = clicked; _ = OpenPreviewAsync(clicked); } async Task ShowPreview() { if (_selectedEnvelope == null) return; await OpenPreviewAsync(_selectedEnvelope); } private async Task OpenPreviewAsync(EnvelopeDto envelope) { // Unmount the viewer first — let the previous instance fully dispose _previewViewerMounted = false; _previewPdfBytes = null; _previewPageCount = 0; _previewPagePreviews = []; _previewFileName = $"envelope-{envelope.Id}.pdf"; _previewErrorMessage = null; _previewLoading = true; _previewVisible = true; await InvokeAsync(StateHasChanged); try { _previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id); if (_previewPdfBytes is null || _previewPdfBytes.Length == 0) { _previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden"); return; } var previewPdfBytes = _previewPdfBytes; _previewPagePreviews = []; try { _previewPagePreviews = (await PdfRenderService.RenderPagesAsync(previewPdfBytes)) .OrderBy(p => p.PageNumber) .ToList(); } catch (Exception renderEx) { Logger.LogWarning(renderEx, "Preview: page image rendering failed for envelope {EnvelopeId}. Falling back to native PDF preview.", envelope.Id); } _previewPageCount = _previewPagePreviews.Count > 0 ? _previewPagePreviews.Count : EstimatePdfPageCount(previewPdfBytes) ?? 1; // PDF ready — mount the viewer now _previewViewerMounted = true; } catch (HttpRequestException ex) when ((int?)ex.StatusCode == 404) { _previewErrorMessage = string.Format( L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."), envelope.Id); Logger.LogWarning("Preview: document not found for envelope {EnvelopeId}", envelope.Id); } catch (Exception ex) { _previewErrorMessage = string.Format( L("ErrorLoadingDocument_WithReason", "Fehler beim Laden des Dokuments: {0}"), ex.Message); Logger.LogError(ex, "Preview: failed to load document for envelope {EnvelopeId}", envelope.Id); } finally { _previewLoading = false; await InvokeAsync(StateHasChanged); } } void OnPreviewClosed() { // Unmount the viewer first — prevent DevExpress async render from // accessing the already-disposed byte array _previewViewerMounted = false; _ = InvokeAsync(async () => { await Task.Yield(); // wait one render cycle for the viewer to be removed from the DOM _previewPdfBytes = null; _previewPageCount = 0; _previewPagePreviews = []; _previewFileName = "document.pdf"; _previewErrorMessage = null; _previewLoading = false; await InvokeAsync(StateHasChanged); }); } Task OnPreviewEditorClickAsync(Microsoft.AspNetCore.Components.Web.MouseEventArgs _) { return Task.CompletedTask; } Task OnPreviewRemoveFieldAsync(int _) { return Task.CompletedTask; } Task OnPreviewMoveFieldAsync(SenderPdfFieldMoveRequest _) { return Task.CompletedTask; } static int? EstimatePdfPageCount(byte[]? documentBytes) { if (documentBytes is null || documentBytes.Length == 0) return null; try { var text = System.Text.Encoding.ASCII.GetString(documentBytes); const string marker = "/Type /Page"; var count = 0; var index = 0; while (true) { index = text.IndexOf(marker, index, StringComparison.Ordinal); if (index < 0) break; count++; index += marker.Length; } return count > 0 ? count : null; } catch { return null; } } // ── Grid Layout Persistence ─────────────────────────────────────────────── // LayoutAutoLoading fires when the grid initializes — restore saved layout. // LayoutAutoSaving fires when the user changes column order/width/sort — persist it. // UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore. // UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT. async Task OnGridLayoutAutoLoading(GridPersistentLayoutEventArgs e) { // Use cached preferences only — avoid HTTP during grid initialization. // If cache is empty (LoadFromServerAsync failed), skip layout restore silently. var prefs = UserPreferencesService.GetCached(); if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json)) { var layout = JsonSerializer.Deserialize(json); SanitizeLayoutFilters(layout); e.Layout = layout; } } async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e) { try { SanitizeLayoutFilters(e.Layout); var json = JsonSerializer.Serialize(e.Layout); await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json); } catch (Exception ex) { await JSRuntime.InvokeVoidAsync("console.warn", $"[Sender] Grid layout save failed: {ex.Message}"); } } // Keep column/order personalization, but never persist/restore data filters. // Tab counters are based on tab datasets; persisted grid filters can hide rows and // make the grid appear empty or incomplete. static void SanitizeLayoutFilters(GridPersistentLayout? layout) { if (layout is null) return; ClearWritableProperty(layout, "FilterCriteria"); ClearWritableProperty(layout, "SearchText"); if (layout.Columns is not System.Collections.IEnumerable columns) return; foreach (var column in columns) { if (column is null) continue; ClearWritableProperty(column, "FilterCriteria"); ClearWritableProperty(column, "FilterValues"); ClearWritableProperty(column, "FilterValue"); ClearWritableProperty(column, "SearchText"); } } static void ClearWritableProperty(object target, string propertyName) { var prop = target.GetType() .GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public) .FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0); if (prop is null || !prop.CanWrite) return; var propertyType = prop.PropertyType; object? value; if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null) { value = null; } else { value = Activator.CreateInstance(propertyType); } prop.SetValue(target, value); } private sealed record StatusFilterItem(EnvelopeStatus Value, string Text); }