Updated `Navigation.NavigateTo` calls across multiple files to set `forceLoad` to `false`, preventing unnecessary full page reloads. - **LanguageControls.razor**: Prevent full reload when switching languages. - **EnvelopeSenderPage.razor**: Avoid reload during logout navigation. - **LoginReceiverPage.razor**: Skip reload after successful envelope receiver login. - **LoginSenderPage.razor**: Prevent reload after successful sender login. These changes improve performance and enhance user experience by enabling smoother page transitions.
1183 lines
58 KiB
Plaintext
1183 lines
58 KiB
Plaintext
@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<EnvelopeSenderPage> Logger
|
|
@inject NavigationManager Navigation
|
|
@inject IJSRuntime JSRuntime
|
|
@inject AppVersionService AppVersion
|
|
@inject IStringLocalizer<Model> ModelLocalizer
|
|
@inject IStringLocalizer<Resource> UiLocalizer
|
|
|
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
|
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
|
|
|
|
<div class="sender-dashboard-layout">
|
|
<div class="sender-action-bar">
|
|
<div class="sender-action-bar__inner">
|
|
<div class="sender-title-section">
|
|
<div class="sender-logo">
|
|
@* signFLOW logo from appsettings.json — fallback to envelope SVG if not loaded *@
|
|
@if (_appLogo is not null && !string.IsNullOrEmpty(_appLogo.Src))
|
|
{
|
|
<img src="@_appLogo.Src"
|
|
style="height: @_appLogo.Height"
|
|
class="@_appLogo.GetClass("Main")"
|
|
alt="signFLOW" />
|
|
}
|
|
else
|
|
{
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z" />
|
|
</svg>
|
|
}
|
|
</div>
|
|
<div class="sender-title" aria-label="signFLOW Portal - Übersicht">
|
|
<span class="sender-title-main">signFLOW Portal</span>
|
|
<span class="sender-title-sub">Übersicht</span>
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Kunden-Logo (Mitte) ───────────────────────────────────────── *@
|
|
<div class="sender-customer-logo-area">
|
|
@if (_companyLogo is not null && !string.IsNullOrEmpty(_companyLogo.Src))
|
|
{
|
|
<img src="@_companyLogo.Src"
|
|
style="--sf-company-logo-height: @_companyLogo.Height"
|
|
class="@_companyLogo.GetClass("Show")"
|
|
alt="Kunden-Logo" />
|
|
}
|
|
else
|
|
{
|
|
<div class="sf-customer-logo-placeholder">Kunden-Logo</div>
|
|
}
|
|
</div>
|
|
|
|
@* ── Toolbar (rechts) ─────────────────────────────────────────── *@
|
|
<div class="sender-toolbar">
|
|
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="@L("Sender_CreateEnvelope_Title", "Neuen Umschlag erstellen")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
|
</svg>
|
|
@L("Sender_CreateEnvelope", "Neuer Umschlag")
|
|
</button>
|
|
|
|
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || !IsEnvelopeEditable(_selectedEnvelope))" title="@L("Sender_EditEnvelope_Title", "Ausgewählten Umschlag bearbeiten")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
|
</svg>
|
|
@L("Sender_Edit", "Bearbeiten")
|
|
</button>
|
|
|
|
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="@L("Sender_DeleteEnvelope_Title", "Ausgewählten Umschlag löschen")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
|
</svg>
|
|
@L("Delete", "Löschen")
|
|
</button>
|
|
|
|
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="@L("Refresh", "Aktualisieren")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
|
|
</svg>
|
|
@if (_isLoading)
|
|
{
|
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
|
}
|
|
</button>
|
|
|
|
<button class="sender-btn" @onclick="ShowPreview" disabled="@(_selectedEnvelope == null)" title="@L("Sender_Preview_Title", "Ausgewählten Umschlag als PDF anzeigen")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
|
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
|
|
</svg>
|
|
@L("Preview", "Vorschau")
|
|
</button>
|
|
|
|
<div class="sender-export-group" title="@L("Sender_ExportView_Title", "Export der aktuellen Tabellenansicht")">
|
|
<select id="sender-export-format" class="sender-export-format" @bind="_selectedExportFormat" aria-label="@L("Sender_ExportFormat_Aria", "Exportformat auswählen")">
|
|
<option value="xlsx">XLSX</option>
|
|
<option value="csv">CSV</option>
|
|
<option value="pdf">PDF</option>
|
|
</select>
|
|
|
|
<label class="sender-export-selected">
|
|
<input type="checkbox" @bind="_exportSelectedRowsOnly" />
|
|
<span class="sender-export-selected-text">
|
|
<span>@L("Only", "Nur")</span>
|
|
<span>@L("Selected", "ausgewählte")</span>
|
|
</span>
|
|
</label>
|
|
|
|
<button class="sender-btn sender-btn--export"
|
|
@onclick="ExportSelectedFormatAsync"
|
|
disabled="@IsExportDisabled()"
|
|
title="@L("Sender_ExportCurrentView_Title", "Aktuelle Ansicht exportieren")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.6a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.6a.5.5 0 0 1 .5-.5z" />
|
|
<path d="M5.354 6.146a.5.5 0 0 1 .707 0L7.5 7.586V.5a.5.5 0 0 1 1 0v7.086l1.439-1.44a.5.5 0 0 1 .707.708l-2.293 2.293a.5.5 0 0 1-.707 0L5.354 6.854a.5.5 0 0 1 0-.708z" />
|
|
</svg>
|
|
@if (_isExporting)
|
|
{
|
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
|
}
|
|
else
|
|
{
|
|
<span>@L("Export", "Exportieren")</span>
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="@L("Logout", "Abmelden")">
|
|
@if (_isLoggingOut)
|
|
{
|
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
|
}
|
|
else
|
|
{
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z" />
|
|
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
|
|
</svg>
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sender-content">
|
|
@if (_isLoading && _allEnvelopes == null)
|
|
{
|
|
<div class="d-flex justify-content-center align-items-center h-100">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
|
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
|
|
</div>
|
|
<p class="text-white fw-semibold">@L("Sender_LoadingEnvelopes", "Umschläge werden geladen...")</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="error-container">
|
|
<div class="alert alert-danger shadow-lg">
|
|
<div class="d-flex align-items-start">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
|
</svg>
|
|
<div>
|
|
<h5 class="mb-2">@L("Sender_LoadError_Title", "Fehler beim Laden der Umschläge")</h5>
|
|
<p class="mb-0">@_errorMessage</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="sender-grid-container">
|
|
<div class="sender-tabs">
|
|
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
|
|
<span>@L("Sender_ActiveEnvelopes", "Aktive Umschläge")</span>
|
|
@if (_activeEnvelopes != null)
|
|
{
|
|
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_activeEnvelopes.Count())</span>
|
|
}
|
|
</button>
|
|
<button class="sender-tab @(_activeTab == "completed" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "completed"'>
|
|
<span>@L("Sender_CompletedEnvelopes", "Abgeschlossene Umschläge")</span>
|
|
@if (_completedEnvelopes != null)
|
|
{
|
|
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="sender-grid-wrapper">
|
|
<DxGrid @key="_activeTab"
|
|
Data="@CurrentGridData"
|
|
@ref="_grid"
|
|
KeyFieldName="Id"
|
|
ShowFilterRow="false"
|
|
FilterMenuButtonDisplayMode="GridFilterMenuButtonDisplayMode.Always"
|
|
ShowSearchBox="true"
|
|
AllowColumnReorder="true"
|
|
AllowSort="true"
|
|
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
|
|
PageSize="20"
|
|
PagerVisible="true"
|
|
SelectionMode="GridSelectionMode.Single"
|
|
AllowSelectRowByClick="true"
|
|
SelectedDataItem="@_selectedEnvelope"
|
|
SelectedDataItemChanged="@((object o) => _selectedEnvelope = o as EnvelopeDto)"
|
|
RowClick="OnRowClick"
|
|
RowDoubleClick="OnRowDoubleClick"
|
|
CustomizeElement="OnCustomizeElement"
|
|
LayoutAutoLoading="OnGridLayoutAutoLoading"
|
|
LayoutAutoSaving="OnGridLayoutAutoSaving">
|
|
<Columns>
|
|
<DxGridDataColumn FieldName="Id" Caption="ID"
|
|
DisplayFormat="0"
|
|
SortIndex="0"
|
|
SortOrder="GridColumnSortOrder.Descending">
|
|
<CellDisplayTemplate Context="cellContext">
|
|
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
|
</CellDisplayTemplate>
|
|
</DxGridDataColumn>
|
|
<DxGridDataColumn FieldName="Title" Caption="@L("Title", "Titel")">
|
|
<CellDisplayTemplate Context="cellContext">
|
|
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
|
</CellDisplayTemplate>
|
|
</DxGridDataColumn>
|
|
<DxGridDataColumn FieldName="Status" Caption="@L("Status", "Status")" ExportEnabled="false">
|
|
<EditSettings>
|
|
<DxComboBoxSettings Data="@GetStatusFilterItems(CurrentGridData)"
|
|
ValueFieldName="Value"
|
|
TextFieldName="Text"
|
|
SearchMode="ListSearchMode.AutoSearch"
|
|
SearchFilterCondition="ListSearchFilterCondition.Contains" />
|
|
</EditSettings>
|
|
<CellDisplayTemplate Context="cellContext">
|
|
@{
|
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
|
if (envelope != null)
|
|
{
|
|
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
|
|
<div class="status-badge status-badge--@statusInfo.CssClass">
|
|
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
|
@statusInfo.Label
|
|
</div>
|
|
}
|
|
}
|
|
</CellDisplayTemplate>
|
|
</DxGridDataColumn>
|
|
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="@L("Receiver", "Empfänger")" ExportEnabled="false">
|
|
<CellDisplayTemplate Context="cellContext">
|
|
@{
|
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
|
if (envelope != null)
|
|
{
|
|
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
|
var signed = GetSignedReceiverCount(envelope);
|
|
var total = receivers.Count;
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<span style="font-size: 0.875rem; color: #6b7280;">
|
|
@signed / @total @L("Signed", "unterschrieben")
|
|
</span>
|
|
@if (total > 0)
|
|
{
|
|
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
|
|
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</CellDisplayTemplate>
|
|
</DxGridDataColumn>
|
|
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="@L("Type", "Typ")"
|
|
Width="120px" AllowSort="true" />
|
|
<DxGridDataColumn FieldName="AddedWhen" Caption="@L("Created", "Erstellt")"
|
|
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
|
<DxGridDataColumn FieldName="ChangedWhen" Caption="@L("ChangedAt", "Geändert am")"
|
|
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
|
</Columns>
|
|
<DetailRowTemplate Context="detailContext">
|
|
<div style="padding: 1rem; background: #f9fafb;">
|
|
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">@L("Receiver", "Empfänger")</h6>
|
|
@{
|
|
var envelope = detailContext.DataItem as EnvelopeDto;
|
|
if (envelope?.EnvelopeReceivers?.Any() == true)
|
|
{
|
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
|
@foreach (var receiver in envelope.EnvelopeReceivers)
|
|
{
|
|
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
|
@{
|
|
var isRejected = IsReceiverRejected(envelope, receiver);
|
|
var isSigned = IsReceiverSigned(envelope, receiver);
|
|
var accessCode = receiver.AccessCode;
|
|
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
|
}
|
|
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
|
|
@if (isRejected)
|
|
{
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
|
</svg>
|
|
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
|
|
}
|
|
else if (isSigned)
|
|
{
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
|
</svg>
|
|
<span>@L("Signed", "Unterschrieben")</span>
|
|
}
|
|
else
|
|
{
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
|
</svg>
|
|
<span>@L("Pending", "Ausstehend")</span>
|
|
}
|
|
</span>
|
|
<div style="flex: 1; font-size: 0.875rem;">
|
|
<strong style="color: #1f2937;">@receiver.Name</strong>
|
|
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
|
</div>
|
|
<div class="sender-access-code">
|
|
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
|
|
<div class="sender-access-code__row">
|
|
<input class="sender-access-code__value"
|
|
value="@(hasAccessCode ? accessCode : "-")"
|
|
readonly
|
|
title="@L("AccessCode", "Access code")" />
|
|
<button type="button"
|
|
class="sender-access-code__copy"
|
|
disabled="@(!hasAccessCode)"
|
|
@onclick='() => CopyAccessCodeAsync(accessCode)'
|
|
title="@L("Copy", "Copy")">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">@L("Sender_NoReceivers", "Keine Empfänger")</p>
|
|
}
|
|
}
|
|
|
|
<h6 style="font-weight: 600; color: #374151; margin: 1rem 0 0.75rem;">@L("History", "Historie")</h6>
|
|
@{
|
|
var historyEntries = envelope is null
|
|
? Enumerable.Empty<HistoryDto>()
|
|
: GetOrderedHistories(envelope);
|
|
if (historyEntries.Any())
|
|
{
|
|
<div class="sender-history-list">
|
|
@foreach (var history in historyEntries)
|
|
{
|
|
var statusInfo = GetStatusInfo(history.Status);
|
|
<div class="sender-history-item">
|
|
<div class="sender-history-item__left">
|
|
<div class="status-badge status-badge--@statusInfo.CssClass">
|
|
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
|
@statusInfo.Label
|
|
</div>
|
|
<span class="sender-history-item__actor">@GetHistoryActor(history)</span>
|
|
</div>
|
|
<div class="sender-history-item__right">
|
|
@GetHistoryDate(history).ToString("dd.MM.yyyy HH:mm")
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">@L("Sender_NoHistory", "Keine Historie vorhanden")</p>
|
|
}
|
|
}
|
|
</div>
|
|
</DetailRowTemplate>
|
|
</DxGrid>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
|
<DxPopup @bind-Visible="@_previewVisible"
|
|
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
|
Width="80vw"
|
|
MaxWidth="80vw"
|
|
Height="90vh"
|
|
MaxHeight="90vh"
|
|
CloseOnOutsideClick="true"
|
|
ShowFooter="false"
|
|
CssClass="sender-preview-popup"
|
|
HeaderCssClass="sender-preview-popup__header"
|
|
BodyCssClass="sender-preview-popup__body"
|
|
Closed="OnPreviewClosed"
|
|
AllowResize="true"
|
|
ApplyBackgroundShading="true"
|
|
Scrollable="true"
|
|
AllowDrag=true
|
|
EnableAdaptivity=true
|
|
ShowHeader=true>
|
|
<BodyContentTemplate>
|
|
@if (_previewLoading)
|
|
{
|
|
<div class="sender-preview-state-wrap">
|
|
<div class="text-center">
|
|
<div class="spinner-border mb-3" style="width:3rem;height:3rem;" role="status">
|
|
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
|
|
</div>
|
|
<p style="color:#6b7280;font-size:0.875rem;">@L("DocumentLoading", "Dokument wird geladen…")</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (_previewErrorMessage is not null)
|
|
{
|
|
<div class="sender-preview-state-wrap">
|
|
<div class="sender-preview-error-card">
|
|
<div class="sender-preview-error-icon">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#dc2626" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p class="sender-preview-error-title">@L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden")</p>
|
|
<p class="sender-preview-error-msg">@_previewErrorMessage</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (_previewPdfBytes is not null && _previewViewerMounted)
|
|
{
|
|
<div class="sender-preview-viewer-wrap">
|
|
<SenderPdfImageEditor IsPlacementMode="false"
|
|
PageCount="@_previewPageCount"
|
|
Pages="@_previewPagePreviews"
|
|
PdfDataUrl="@PreviewPdfDataUrl"
|
|
PdfFileName="@_previewFileName"
|
|
Fields="@_previewFieldOverlays"
|
|
EditorClick="OnPreviewEditorClickAsync"
|
|
RemoveField="OnPreviewRemoveFieldAsync"
|
|
MoveField="OnPreviewMoveFieldAsync" />
|
|
</div>
|
|
}
|
|
</BodyContentTemplate>
|
|
</DxPopup>
|
|
|
|
<DxPopup @bind-Visible="@_deletePopupVisible"
|
|
HeaderText="@L("Sender_DeleteEnvelope_Header", "Umschlag löschen")"
|
|
Width="560px"
|
|
MaxWidth="95vw"
|
|
ShowFooter="true"
|
|
CloseOnOutsideClick="false"
|
|
ShowCloseButton="true"
|
|
CloseOnEscape="true"
|
|
ShowHeader="true"
|
|
CssClass="sender-delete-popup">
|
|
<BodyContentTemplate>
|
|
<div style="display:flex;flex-direction:column;gap:0.75rem;">
|
|
<p style="margin:0;color:#374151;font-size:0.9rem;line-height:1.45;">
|
|
@L("Sender_DeleteEnvelope_ReasonPrompt", "Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.")
|
|
</p>
|
|
@if (_selectedEnvelope is not null)
|
|
{
|
|
<p style="margin:0;color:#6b7280;font-size:0.825rem;">
|
|
@L("Envelope", "Umschlag"): <strong>#@_selectedEnvelope.Id - @_selectedEnvelope.Title</strong>
|
|
</p>
|
|
}
|
|
<DxMemo @bind-Text="_deleteReason"
|
|
NullText="@L("Sender_DeleteEnvelope_ReasonPlaceholder", "Begründung eingeben...")"
|
|
Rows="5"
|
|
MaxLength="2000"
|
|
CssClass="w-100" />
|
|
@if (!string.IsNullOrWhiteSpace(_deleteErrorMessage))
|
|
{
|
|
<div class="alert alert-danger" style="margin:0;padding:0.5rem 0.75rem;font-size:0.85rem;">
|
|
@_deleteErrorMessage
|
|
</div>
|
|
}
|
|
</div>
|
|
</BodyContentTemplate>
|
|
<FooterContentTemplate>
|
|
<div style="display:flex;justify-content:flex-end;gap:0.5rem;width:100%;">
|
|
<DxButton Text="@L("Cancel", "Abbrechen")"
|
|
RenderStyle="ButtonRenderStyle.Secondary"
|
|
Click="CloseDeletePopup"
|
|
Enabled="@(!_isDeletingEnvelope)" />
|
|
<DxButton Text="@L("Delete", "Löschen")"
|
|
RenderStyle="ButtonRenderStyle.Danger"
|
|
Click="ConfirmDeleteEnvelopeAsync"
|
|
Enabled="@(!_isDeletingEnvelope)" />
|
|
</div>
|
|
</FooterContentTemplate>
|
|
</DxPopup>
|
|
|
|
@code {
|
|
private IEnumerable<EnvelopeDto>? _allEnvelopes;
|
|
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
|
|
private IEnumerable<EnvelopeDto>? _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<PdfPageImageDto> _previewPagePreviews = [];
|
|
private string _previewFileName = "document.pdf";
|
|
private string? _previewErrorMessage = null;
|
|
private readonly IReadOnlyList<SenderPdfFieldOverlay> _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<EnvelopeDto> CurrentGridData =>
|
|
(_activeTab == "completed" ? _completedEnvelopes : _activeEnvelopes) ?? Enumerable.Empty<EnvelopeDto>();
|
|
|
|
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<HistoryDto> GetOrderedHistories(EnvelopeDto envelope)
|
|
{
|
|
return (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
|
|
.OrderByDescending(GetHistoryDate)
|
|
.Take(20);
|
|
}
|
|
|
|
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
|
|
{
|
|
return (source ?? Enumerable.Empty<EnvelopeDto>())
|
|
.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<HistoryDto>())
|
|
.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<GridPersistentLayout>(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);
|
|
}
|