Replaced `DxPdfViewer` with `SenderPdfImageEditor` to enable PDF rendering, page previews, and field overlay editing. Added dependency injection for `PdfRenderService` and new state fields (`_previewPageCount`, `_previewPagePreviews`, `_previewFileName`, `_previewFieldOverlays`) to manage PDF metadata and interactions. Updated `OpenPreviewAsync` to render PDF pages using `PdfRenderService` and handle errors gracefully. Introduced `PreviewPdfDataUrl` for generating data URLs and a static helper `EstimatePdfPageCount` for fallback page count estimation. Added placeholder methods for user interactions with the editor. Improved cleanup logic to reset state fields when the viewer is unmounted. These changes enhance the PDF preview functionality while maintaining backward compatibility.
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: true);
|
|
}
|
|
|
|
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);
|
|
}
|