Compare commits

...

24 Commits

Author SHA1 Message Date
01cd8b2607 Improve publish script with dynamic versioning
Added dynamic version resolution from the `<Version>` tag in the
`.csproj` file, enabling versioned output directories. Updated
`setlocal` to use `enabledelayedexpansion` for better variable
handling. Enhanced logging of build parameters and streamlined
the "Publishing" step by removing redundant logs.

Improved output verification with additional checks for critical
files (`hostfxr.dll`, `coreclr.dll`, etc.), logging failures if
files are missing. Updated success messages to include the
resolved version and generalized next-step instructions.
2026-09-21 14:17:49 +02:00
310d2efc6e Update project version to 1.1.0-beta
Updated versioning details in `EnvelopeGenerator.Server.csproj`:
- Changed `<Version>` from `1.0.1-beta` to `1.1.0-beta`.
- Updated `<FileVersion>` from `1.0.1.0` to `1.1.0.0`.
- Updated `<AssemblyVersion>` from `1.0.1.0` to `1.1.0.0`.

This minor version update reflects new features or improvements
while maintaining backward compatibility.
2026-09-21 13:55:42 +02:00
e5402bafc9 Refactor and cleanup EnvelopeSender components
Refactored code for improved readability, consistency, and maintainability. Removed the WASM boot loading overlay and its associated styles/scripts. Updated SVG paths to use self-closing tags for HTML compliance. Reformatted conditional blocks, state management, and session data logic. Enhanced error handling, spinner visibility, and empty state displays.

Removed redundant comments and unused code. Added `envelope-editor.js` for new functionality. Cleaned up CSS and JavaScript related to the removed WASM overlay. Improved grid layout persistence and signature field logic. Adjusted localization strings for better alignment with German translations.
2026-09-21 13:05:59 +02:00
f517ab4cfb Enhance grid UI and refactor boot overlay logic
- Updated `MappingProfile.cs` to add detailed mapping for `Envelope` to `EnvelopeDto`, including `EnvelopeTypeTitle`.
- Added a grid persistence hint banner in `EnvelopeSenderPage.razor` with an SVG icon and German message.
- Introduced three new columns (`EnvelopeTypeTitle`, `AddedWhen`, `ChangedWhen`) to the grid in `EnvelopeSenderPage.razor`.
- Refactored boot overlay removal logic in `App.razor` into a reusable function `sfRemoveBootOverlay`.
- Simplified event handling for overlay removal and fallback logic in `App.razor`.
- Adjusted `.sender-grid-wrapper` padding in `sender-page.css` and added styles for the new grid persistence hint banner.
2026-09-21 12:03:27 +02:00
6d63f9db2b Update Default connection string in appsettings.json
The `ConnectionStrings.Default` value in `appsettings.json` was updated.
The connection string now points to the testing environment
(`SDD-VHP04-SQL19\DD_TESTING01`) instead of the development environment
(`SDD-VMP04-SQL17\DD_DEVELOP01`). The password was also updated to
`123456789dD!` to reflect the new environment credentials.
2026-09-21 11:07:50 +02:00
46c7c31e03 Add customer logo area to action bar
Introduced a customer logo section in `EnvelopeSenderPage.razor` that dynamically displays a customer logo or a placeholder when no logo is configured. The logo is fetched from the `CustomImagesService` and styled using new CSS classes in `sender-page.css`.

- Added `_companyLogo` property to store customer logo configuration.
- Updated `CustomImagesService` to fetch both app and customer logos.
- Modified `appsettings.json` to include logo paths and styles.
- Enhanced CSS for a three-section action bar layout.
- Added placeholder styling for missing customer logos.
- Updated initialization logic to load logos dynamically.
- Documented implementation details in `SF-74-PLAN.md`.

The implementation is modular and deployment-specific, allowing logo paths and styles to be updated via configuration without code changes.
2026-09-06 10:50:05 +02:00
8f6db435bb SF-74: Client UI — IndexPage + EnvelopeSenderPage logo integration (signFLOW + company logo, branding layout) 2026-09-04 01:07:34 +02:00
9bf7676464 SF-74: Client — CustomImagesService (in-memory cache, GET /api/CustomImages), DI registration 2026-09-04 01:07:27 +02:00
d9556c18ca SF-74: Server wwwroot — logo images (DD_signFLOW_LOGO.png, digital_data.svg), app.css logo CSS classes 2026-09-04 01:07:16 +02:00
5b7157f422 SF-74: Server — CustomImagesController (GET /api/CustomImages), DI registration, appsettings logo config, App.razor 2026-09-04 01:07:04 +02:00
119ee3ec16 SF-74: Models — CustomImagesOptions (Server, Options Pattern), CustomImagesDto (Client) 2026-09-04 01:06:52 +02:00
b536b31a4b SF-74: CSS — app.css theme/dark-mode additions, envelope-viewer.css, sender-page.css new stylesheets 2026-09-03 15:40:20 +02:00
a80efee075 SF-74: ThemeService + signflow-theme.js — JS interop fixes and dark mode init improvements 2026-09-03 15:40:14 +02:00
86d6daafbd SF-74: EnvelopeSenderPage — ThemeService/UserPreferencesService inject, LoadFromServerAsync, grid layout persistence (LayoutAutoLoading/Saving) 2026-09-03 14:58:31 +02:00
aefa1a8883 SF-74: Client UI — MainLayout HeadContent+footer theme controls, ThemeControls component, app.css sf-footer styles 2026-09-03 14:58:19 +02:00
b3e7f77165 SF-74: Server — add signflow-theme.js (localStorage/system dark mode JS interop) 2026-09-03 14:58:07 +02:00
bac9bfd036 SF-74: Client Services — ThemeService (debounce, SaveError, DisplayName), UserPreferencesService (1h cache) 2026-09-03 14:57:55 +02:00
6effb8a0d6 SF-74: Server — UserPreferencesController, claim extensions, DI registration, App.razor 2026-09-03 14:57:44 +02:00
a9ff0d88f2 SF-74: Infrastructure — UserPreferencesService (IDistributedCache, no try-catch) 2026-09-03 14:57:32 +02:00
0fdfe991ea SF-74: Domain/Application — UserPreferences DTO, interface, Sender role constant 2026-09-03 14:57:21 +02:00
7fc2c14f9c Add dynamic theme and dark mode functionality
Introduced `ThemeService` to manage themes and dark mode, with a new footer UI for theme selection and toggling dark mode. Added dynamic CSS loading and Bootstrap theme support. Registered `UserPreferencesService` and `ThemeService` for SSR compatibility. Removed hardcoded DevExpress theme links.

Fixed German localization issues in `LoginReceiverPage` and `LoginSenderPage`. Updated `app.css` with styles for the new footer controls. Simplified font loading in `MainLayout.razor`.
2026-09-03 09:24:13 +02:00
5098deedd1 Add ThemeService and UserPreferencesService
Introduce ThemeService to manage themes and dark mode state,
including initialization, persistence, and server sync.

Add UserPreferencesService to handle API communication for
retrieving and saving user preferences with in-memory caching
to reduce network overhead.

Register both services in the DI container in Program.cs.

Enhance ThemeService with detailed documentation and introduce
ThemeOption record for selectable themes. Improve server-side
caching in UserPreferencesService for consistency and efficiency.
2026-09-03 01:18:01 +02:00
2e634361c5 Add user preferences management via distributed cache
Introduced `UserPreferencesDto` to represent UI preferences,
including theme, dark mode, and grid layouts. Added the
`IUserPreferencesService` interface and implemented it in
`UserPreferencesService` to manage preferences stored in
the distributed cache (`dbo.TBDD_CACHE`).

Registered `UserPreferencesService` in dependency injection
and added a NuGet reference for `Microsoft.Extensions.Caching.Abstractions`.

Created `UserPreferencesController` with `GET` and `PUT`
endpoints to retrieve and save user preferences. Validated
theme names in the `PUT` endpoint.

Updated `Program.cs` to ensure proper service registration.
2026-09-02 12:35:19 +02:00
60ebebc0d8 Add widget annotation and update stroke color
Updated `createAnnotations` to initialize a `PSPDFKit.Annotations.WidgetAnnotation` object, enabling the creation of widget annotations.

Modified `ctx.strokeStyle` in `createAnnotationFrameBlob` to use 'darkblue' instead of 'darkgray', improving the visual appearance of the annotation frame.
2026-08-27 14:46:32 +02:00
36 changed files with 2247 additions and 215 deletions

View File

@@ -0,0 +1,39 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Represents a user's UI preferences stored in the distributed cache (dbo.TBDD_CACHE).
/// </summary>
public sealed record UserPreferencesDto
{
/// <summary>
/// The active DevExpress theme name.
/// Allowed values: "blazing-berry", "fluent", "purple"
/// </summary>
public string ThemeName { get; set; } = "blazing-berry";
/// <summary>
/// Whether dark mode is active.
/// Default: false (light mode).
/// </summary>
public bool IsDarkMode { get; set; } = false;
/// <summary>
/// Persisted DxGrid layout states, keyed by a stable grid identifier.
/// Example keys: "sender.active-envelopes", "sender.completed-envelopes"
/// Values are opaque JSON strings produced by DxGrid.SaveLayoutAsync().
/// </summary>
public Dictionary<string, string> GridLayouts { get; set; } = new();
/// <summary>
/// The authenticated user's display name ("{GivenName} {Surname}").
/// Populated by the server on GET responses from JWT claims.
/// NOT persisted in cache — injected at read time by UserPreferencesController.
/// Ignored on PUT requests.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
///
/// </summary>
public static UserPreferencesDto Default { get; } = new UserPreferencesDto();
}

View File

@@ -0,0 +1,29 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE) as JSON.
/// Cache key schema: "signflow.user-preferences:{username}"
/// </summary>
public interface IUserPreferencesService
{
/// <summary>
/// Retrieves preferences for the given user.
/// Always returns a valid object — never throws, never returns null.
/// Returns defaults if no entry exists in cache.
/// </summary>
/// <param name="id"></param>
/// <param name="ct">Cancellation token.</param>
Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default);
/// <summary>
/// Persists the given preferences for the user in the distributed cache.
/// Failures are non-fatal and silently ignored.
/// </summary>
/// <param name="id"></param>
/// <param name="preferences">The preferences to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default);
}

View File

@@ -28,7 +28,9 @@ public class MappingProfile : Profile
CreateMap<DocReceiverElement, DocReceiverElementDto>();
CreateMap<DocumentStatus, DocumentStatusDto>();
CreateMap<EmailTemplate, EmailTemplateDto>();
CreateMap<Envelope, EnvelopeDto>();
CreateMap<Envelope, EnvelopeDto>()
.ForMember(dest => dest.EnvelopeTypeTitle,
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
CreateMap<Document, DocumentDto>();
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));

View File

@@ -1,5 +1,6 @@
#if NETFRAMEWORK
using System;
using System.Collections.Generic;
#endif
namespace EnvelopeGenerator.Domain.Constants
@@ -15,7 +16,11 @@ namespace EnvelopeGenerator.Domain.Constants
public static class Receiver
{
public const string TFA = "EGReceiverTFA";
[Obsolete("Use Receiver.Authorized")]
public const string Full = "EGReceiver";
public static readonly IReadOnlyList<string> Authorized = new[] { "receiver", "EGReceiver" };
}
public const string Sender = "EGSender";

View File

@@ -14,6 +14,7 @@ using EnvelopeGenerator.Infrastructure.Repositories;
using EnvelopeGenerator.Infrastructure.Executor;
using EnvelopeGenerator.Application.Common.Interfaces.Repositories;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
#elif NETFRAMEWORK
using System;
@@ -55,6 +56,9 @@ namespace EnvelopeGenerator.Infrastructure
services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>();
services.TryAddScoped<IReceiverRepository, ReceiverRepository>();
services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>();
// User Preferences — distributed cache (dbo.TBDD_CACHE)
services.TryAddScoped<IUserPreferencesService, UserPreferencesService>();
#endif
services.AddDbRepository(opt =>

View File

@@ -26,6 +26,7 @@
<PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="QuestPDF" Version="2025.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />

View File

@@ -0,0 +1,46 @@
#if NET
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Infrastructure;
/// <summary>
/// Implements user preferences storage using the distributed cache (dbo.TBDD_CACHE).
/// No DB schema changes required — uses the existing SQL Server distributed cache table.
/// Cache key schema: "signflow.user-preferences:{username}"
/// Expiration: indefinite (no TTL) — preferences persist until explicitly overwritten.
/// </summary>
public sealed class UserPreferencesService(IDistributedCache cache) : IUserPreferencesService
{
private const string KeyPrefix = "signflow.user-preferences:";
private static string BuildKey(string id) => $"{KeyPrefix}{id}";
/// <inheritdoc />
public async Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default)
{
var json = await cache.GetStringAsync(BuildKey(id), ct);
if(json is not null && JsonSerializer.Deserialize<UserPreferencesDto>(json) is UserPreferencesDto pref)
{
return pref;
}
else
{
// init new
await SaveAsync(id, UserPreferencesDto.Default, ct);
return UserPreferencesDto.Default;
}
}
/// <inheritdoc />
public async Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(preferences);
// No expiration — preferences are stored indefinitely.
await cache.SetStringAsync(BuildKey(id), json, new DistributedCacheEntryOptions(), ct);
}
}
#endif

View File

@@ -1,28 +1,43 @@
@using EnvelopeGenerator.Server.Client.Services;
@using EnvelopeGenerator.Server.Client.Services
@inherits LayoutComponentBase
@implements IDisposable
<div class="page">
<main>
@inject ThemeService ThemeService
<HeadContent>
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
</HeadContent>
<div class="page" data-bs-theme="@ThemeService.BootstrapTheme">
<main>
<article class="content">
@Body
</article>
</main>
<footer class="receiver-footer">
<div class="receiver-footer__left">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
</div>
<ThemeControls />
</footer>
</div>
@code {
[Inject] IHttpClientFactory HttpClientFactory { get; set; } = default!;
List<string> RequiredFonts = new() {
"opensans.ttf"
};
private Action? _themeChangeHandler;
protected async override Task OnInitializedAsync() {
await FontLoader.LoadFonts(HttpClientFactory, RequiredFonts);
protected override async Task OnInitializedAsync()
{
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
await base.OnInitializedAsync();
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -0,0 +1,95 @@
@using EnvelopeGenerator.Server.Client.Services
@rendermode InteractiveWebAssembly
@implements IDisposable
@inject ThemeService ThemeService
<div class="sf-theme-slider">
@* Moon — left end (blazing-dark) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "blazing-dark" ? "sf-theme-icon--active" : "")"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
title="Blazing Dark">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
@* Track wrapper: range input + left/mid/right ticks *@
<div class="sf-theme-track-wrapper">
@* Left tick *@
<div class="sf-theme-tick sf-theme-tick--left"></div>
@* Mid tick (marks the middle position) *@
<div class="sf-theme-tick sf-theme-tick--mid"></div>
@* Right tick *@
<div class="sf-theme-tick sf-theme-tick--right"></div>
<input type="range"
class="sf-theme-range"
min="0" max="2" step="1"
value="@SliderValue"
@oninput="OnSliderChanged"
title="@ThemeService.AvailableThemes[SliderValue].Label" />
</div>
@* Sun — right end (purple) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "purple" ? "sf-theme-icon--active" : (ThemeService.ThemeName == "blazing-berry" ? "sf-theme-icon--mid" : ""))"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1.5"
title="Blazing Berry / Purple">
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</svg>
</div>
@code {
private Action? _themeChangeHandler;
/// <summary>
/// Maps the active theme to a 0-based slider step.
/// 0 = blazing-dark (🌙 left)
/// 1 = blazing-berry (middle)
/// 2 = purple (☀️ right)
/// </summary>
private int SliderValue => ThemeService.ThemeName switch
{
"blazing-dark" => 0,
"purple" => 2,
_ => 1
};
protected override void OnInitialized()
{
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await ThemeService.InitializeAsync();
}
private async Task OnSliderChanged(ChangeEventArgs e)
{
if (!int.TryParse(e.Value?.ToString(), out var step)) return;
var themeName = step switch
{
0 => "blazing-dark",
2 => "purple",
_ => "blazing-berry"
};
await ThemeService.SetThemeAsync(themeName);
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -0,0 +1,23 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Client-side DTO mirroring CustomImageEntry on the server.
/// Height is a CSS value string (e.g. "28px") set in appsettings.json.
/// </summary>
public sealed record CustomImageDto(string Src, string Height, Dictionary<string, string> Classes)
{
public CustomImageDto() : this(string.Empty, "28px", new()) { }
/// <summary>Returns the CSS class for the given context key, or empty string if absent.</summary>
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) && cls is not null ? cls : string.Empty;
}
/// <summary>Client-side DTO for the full CustomImages config (App + Company logos).</summary>
public sealed record CustomImagesDto(CustomImageDto App, CustomImageDto Company)
{
public CustomImagesDto() : this(new(), new()) { }
}

View File

@@ -5,19 +5,16 @@
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@using EnvelopeGenerator.Application.Common.Dto
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@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 NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
@@ -26,53 +23,84 @@
<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"/>
<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">Umschlag-Übersicht</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="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="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"/>
<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>
Neuer Umschlag
</button>
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" 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"/>
<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>
Bearbeiten
</button>
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" 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"/>
<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öschen
</button>
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="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"/>
<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) {
@if (_isLoading)
{
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
}
</button>
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
@if (_isLoggingOut) {
@if (_isLoggingOut)
{
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
} else {
}
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"/>
<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>
@@ -81,7 +109,8 @@
</div>
<div class="sender-content">
@if (_isLoading && _allEnvelopes == null) {
@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">
@@ -90,13 +119,15 @@
<p class="text-white fw-semibold">Umschläge werden geladen...</p>
</div>
</div>
} else if (_errorMessage != null) {
}
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"/>
<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">Fehler beim Laden der Umschläge</h5>
@@ -105,25 +136,39 @@
</div>
</div>
</div>
} else {
}
else
{
<div class="sender-grid-container">
<div class="sender-tabs">
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
<span>Aktive Umschläge</span>
@if (_activeEnvelopes != null) {
@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>Abgeschlossene Umschläge</span>
@if (_completedEnvelopes != null) {
@if (_completedEnvelopes != null)
{
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
}
</button>
</div>
<div class="sender-grid-wrapper">
@if (_activeTab == "active") {
@* Grid persistence hint — informs users their layout is auto-saved *@
<div class="sf-grid-persistence-hint">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" 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="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
</div>
@if (_activeTab == "active")
{
<DxGrid Data="@_activeEnvelopes"
@ref="_gridActive"
ShowFilterRow="true"
@@ -136,7 +181,9 @@
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
@@ -154,7 +201,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@@ -168,7 +216,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
var signed = receivers.Count(r => r.Signed);
var total = receivers.Count;
@@ -176,7 +225,8 @@
<span style="font-size: 0.875rem; color: #6b7280;">
@signed / @total unterschrieben
</span>
@if (total > 0) {
@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>
@@ -186,26 +236,37 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="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;">Empfänger</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true) {
if (envelope?.EnvelopeReceivers?.Any() == true)
{
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers) {
@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;">
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
@if (receiver.Signed) {
@if (receiver.Signed)
{
<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"/>
<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>Unterschrieben</span>
} else {
}
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"/>
<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>Ausstehend</span>
}
@@ -217,14 +278,18 @@
</div>
}
</div>
} else {
}
else
{
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
}
}
</div>
</DetailRowTemplate>
</DxGrid>
} else {
}
else
{
<DxGrid Data="@_completedEnvelopes"
@ref="_gridCompleted"
ShowFilterRow="true"
@@ -234,7 +299,9 @@
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
@@ -252,7 +319,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@@ -266,7 +334,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
var signed = receivers.Count(r => r.Signed);
var total = receivers.Count;
@@ -274,7 +343,8 @@
<span style="font-size: 0.875rem; color: #6b7280;">
@signed / @total unterschrieben
</span>
@if (total > 0) {
@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>
@@ -284,26 +354,37 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="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;">Empfänger</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true) {
if (envelope?.EnvelopeReceivers?.Any() == true)
{
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers) {
@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;">
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
@if (receiver.Signed) {
@if (receiver.Signed)
{
<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"/>
<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>Unterschrieben</span>
} else {
}
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"/>
<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>Ausstehend</span>
}
@@ -315,7 +396,9 @@
</div>
}
</div>
} else {
}
else
{
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
}
}
@@ -341,6 +424,14 @@
private DxGrid? _gridActive;
private DxGrid? _gridCompleted;
// Logo config loaded from /api/CustomImages after auth
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _companyLogo;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
@@ -350,6 +441,17 @@
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();
}
@@ -367,13 +469,10 @@
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded {_activeEnvelopes.Count()} active and {_completedEnvelopes.Count()} completed envelopes");
}
catch (Exception ex)
{
_errorMessage = ex.Message;
await JSRuntime.InvokeVoidAsync("console.error", "Fehler beim Laden der Umschläge:", ex.ToString());
}
finally
{
@@ -396,20 +495,19 @@
{
if (_selectedEnvelope == null) return;
// TODO: Navigate to envelope editor
JSRuntime.InvokeVoidAsync("console.log", $"Edit envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
void DeleteEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Show delete confirmation dialog
JSRuntime.InvokeVoidAsync("console.log", $"Delete envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
async Task LogoutAsync()
{
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
UserPreferencesService.InvalidateCache();
await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true);
}
@@ -446,4 +544,45 @@
{
_selectedEnvelope = envelope as EnvelopeDto;
}
// ── 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 OnActiveGridLayoutAutoLoading(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(GridKeyActive, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("console.warn", $"[Sender] Grid layout save failed: {ex.Message}");
}
}
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
}
}

View File

@@ -1,17 +1,27 @@
@page "/"
@inject IJSRuntime JS
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@rendermode InteractiveWebAssembly
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="home-page-wrapper">
<div class="home-hero-header">
<div class="home-hero-header__inner">
@* signFLOW logo from appsettings.json CustomImages.App — fallback to SVG if not loaded *@
@if (_appLogo is not null && !string.IsNullOrEmpty(_appLogo.Src))
{
<img src="@_appLogo.Src"
style="height: @_appLogo.Height"
class="home-hero-header__icon @_appLogo.GetClass("Main")"
alt="signFLOW" />
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="home-hero-header__icon" 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>
<h1 class="home-hero-header__title">SignFlow</h1>
<p class="home-hero-header__subtitle">Willkommen im eSign-Portal</p>
@@ -75,11 +85,18 @@
"Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. " +
"Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.";
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
protected override async Task OnInitializedAsync()
{
// Load logo config early — 401 here is normal (not yet logged in), service returns defaults silently
var logos = await CustomImagesService.GetAsync();
_appLogo = logos.App;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (!firstRender) return;
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
}
}
}

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -15,14 +13,14 @@
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
</div>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
</div>
<div class="card-body p-4">
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
</p>
@if (LoginResult == EnvelopeLoginResult.NotFound)
@@ -33,7 +31,7 @@
</svg>
<div>
<strong>Dokument nicht gefunden.</strong><br />
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
</div>
</div>
}
@@ -44,7 +42,7 @@
<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>
<div>
<strong>Ungültiger Zugangscode.</strong><br />
<strong>Ungültiger Zugangscode.</strong><br />
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
</div>
</div>
@@ -58,7 +56,7 @@
</svg>
<div>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div>
</div>
}
@@ -115,14 +113,14 @@
@if (IsLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Überprüfen …</span>
<span>Überprüfen …</span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
<span>Dokument öffnen</span>
<span>Dokument öffnen</span>
}
</button>

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -32,7 +30,7 @@
<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>
<div>
<strong>Ungültige Anmeldedaten.</strong><br />
<strong>Ungültige Anmeldedaten.</strong><br />
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
</div>
</div>
@@ -44,7 +42,7 @@
</svg>
<div>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div>
</div>
}
@@ -120,7 +118,7 @@
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
@if (IsLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Anmelden …</span>
<span>Anmelden …</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>

View File

@@ -34,6 +34,13 @@ builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();
builder.Services.AddScoped<ThemeService>();
// Logo configuration (loaded once after login, in-memory cached)
builder.Services.AddScoped<CustomImagesService>();
// DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();

View File

@@ -0,0 +1,55 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Fetches and caches the CustomImages configuration (signFLOW + company logos)
/// from GET /api/CustomImages.
///
/// Config changes rarely (deployment-time only), so results are cached in-memory
/// for the lifetime of the service (WASM app session). No TTL needed.
/// </summary>
public class CustomImagesService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
private const string Endpoint = "/api/CustomImages";
private CustomImagesDto? _cached;
/// <summary>
/// Returns logo configuration. Fetches once from server; all subsequent calls
/// are served from memory with zero network overhead.
/// Returns defaults (empty Src) on auth failure (401) — components must handle gracefully.
/// </summary>
public async Task<CustomImagesDto> GetAsync(CancellationToken ct = default)
{
if (_cached is not null)
return _cached;
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
if (!response.IsSuccessStatusCode)
{
// Not authenticated yet (login page) — return defaults silently.
_cached = new CustomImagesDto();
return _cached;
}
_cached = await response.Content.ReadFromJsonAsync<CustomImagesDto>(_json, ct)
?? new CustomImagesDto();
return _cached;
}
catch (Exception)
{
// Network error or server unavailable — return defaults so UI still renders.
return new CustomImagesDto();
}
}
/// <summary>Call after logout to force re-fetch on next login.</summary>
public void Invalidate() => _cached = null;
}

View File

@@ -0,0 +1,232 @@
using Microsoft.JSInterop;
using Microsoft.Extensions.Logging;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Manages the active DevExpress theme for the WASM client.
///
/// Theme selection is the sole UI control — no separate dark/light toggle.
/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
///
/// Initialization:
/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
///
/// Persistence:
/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary>
public class ThemeService : IAsyncDisposable
{
// ── Allowed themes (ordered dark → light for range slider) ───────────────
// DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
[
new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
new("Blazing Berry", "blazing-berry"), // index 1 — middle
new("Purple", "purple"), // index 2 — ☀️
];
private const string LocalStorageThemeKey = "signflow.theme";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
private readonly IJSRuntime _js;
private readonly UserPreferencesService _preferencesService;
private readonly ILogger<ThemeService> _logger;
// ── Debounce ──────────────────────────────────────────────────────────────
private CancellationTokenSource? _saveCts;
// ── Change & error notification ───────────────────────────────────────────
/// <summary>Fired whenever theme changes. Subscribers call StateHasChanged.</summary>
public event Action? OnChange;
/// <summary>
/// Set when a background save fails. UI layer can surface this as a warning.
/// Cleared on the next successful save or theme change.
/// </summary>
public Exception? SaveError { get; private set; }
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
{
_js = js;
_preferencesService = preferencesService;
_logger = logger;
}
// ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName;
/// <summary>True when blazing-dark is active — used for Bootstrap data-bs-theme.</summary>
public bool IsDarkMode => _themeName == "blazing-dark";
public string? DisplayName { get; private set; }
/// <summary>
/// CSS href for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
/// </summary>
public string ThemeCssHref =>
$"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ────────────────────────────────────────────────────────
/// <summary>
/// Called once in OnAfterRenderAsync(firstRender: true).
/// Reads theme from localStorage; falls back to system dark-mode preference.
/// Safe to call multiple times — subsequent calls are no-ops.
/// </summary>
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageThemeKey);
if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
{
_themeName = stored;
}
else
{
// No stored theme — use system preference to pick dark or light
var prefersDark = await _js.InvokeAsync<bool>(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
}
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
/// <summary>
/// Called after successful sender login.
/// Syncs theme from the server cache; on first login propagates local theme to server.
/// </summary>
public async Task LoadFromServerAsync(CancellationToken ct = default)
{
var prefs = await _preferencesService.GetAsync(ct);
var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode;
if (isFirstLogin && _themeName != "blazing-berry")
{
// Propagate locally-detected theme to server on first login
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
// Persist resolved theme to localStorage
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
DisplayName = prefs.DisplayName;
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
// ── Mutation ──────────────────────────────────────────────────────────────
/// <summary>
/// Sets the active theme, applies it immediately, and schedules a server save.
/// State change is applied regardless of whether the save succeeds.
/// </summary>
public async Task SetThemeAsync(string themeName)
{
if (themeName == _themeName) return;
if (!AvailableThemes.Any(t => t.Value == themeName))
throw new ArgumentException($"Theme '{themeName}' is not allowed.", nameof(themeName));
_themeName = themeName;
SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
await ScheduleSaveAsync();
}
// ── Private helpers ───────────────────────────────────────────────────────
private void ApplyPreferences(UserPreferencesDto prefs)
{
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
? prefs.ThemeName
: "blazing-berry";
// Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
if (prefs.IsDarkMode && _themeName == "blazing-berry")
_themeName = "blazing-dark";
}
private async Task ApplyThemeToDocumentAsync()
{
try
{
await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme, _themeName);
}
catch (Exception ex)
{
// JS unavailable during server prerender — <HeadContent> link is the SSR fallback.
_logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
}
}
private async Task ScheduleSaveAsync()
{
_saveCts?.Cancel();
_saveCts = new CancellationTokenSource();
try
{
await Task.Delay(500, _saveCts.Token);
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
// Newer save scheduled — intentionally cancelled.
}
catch (Exception ex)
{
_logger.LogError(ex, "Background save of user preferences failed.");
SaveError = ex;
OnChange?.Invoke();
}
}
public ValueTask DisposeAsync()
{
_saveCts?.Cancel();
_saveCts?.Dispose();
return ValueTask.CompletedTask;
}
}
/// <summary>Describes a selectable DevExpress theme.</summary>
/// <param name="Label">User-facing display name.</param>
/// <param name="Value">CSS theme identifier.</param>
public sealed record ThemeOption(string Label, string Value);

View File

@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using Microsoft.Extensions.Logging;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Retrieves and persists user UI preferences via the server API.
/// The server stores preferences in the distributed cache (dbo.TBDD_CACHE).
///
/// Client-side memory cache:
/// Preferences are cached in-memory for <see cref="CacheDuration"/> after the first fetch.
/// All reads (GetAsync, GetCachedAsync) are served from cache after the first load.
/// Writes (SaveAsync) update both the cache and the server atomically.
/// Cache is invalidated explicitly via InvalidateCache().
/// </summary>
public class UserPreferencesService(
IHttpClientFactory clientFactory,
ILogger<UserPreferencesService> logger)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private const string Endpoint = "/api/UserPreferences";
// ── Client-side memory cache ──────────────────────────────────────────────
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
private UserPreferencesDto? _cached;
private DateTimeOffset _cachedAt = DateTimeOffset.MinValue;
private bool IsCacheValid => _cached is not null
&& DateTimeOffset.UtcNow - _cachedAt < CacheDuration;
/// <summary>
/// Returns preferences from in-memory cache if valid; otherwise fetches from the server.
/// On first call after login this issues one HTTP GET — all subsequent calls within
/// <see cref="CacheDuration"/> are served from memory with zero network overhead.
/// </summary>
public async Task<UserPreferencesDto> GetAsync(CancellationToken ct = default)
{
if (IsCacheValid)
return _cached!;
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
// Not authenticated yet (login page) or no preferences saved — return defaults.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
SetCache(new UserPreferencesDto());
return _cached!;
}
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? new UserPreferencesDto();
SetCache(dto);
return _cached!;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to load user preferences from server.");
throw;
}
}
/// <summary>
/// Returns the cached preferences without hitting the network.
/// Returns null if the cache has not been populated yet (before first GetAsync call).
/// </summary>
public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null;
/// <summary>
/// Persists the given preferences on the server and updates the in-memory cache.
/// </summary>
public async Task<UserPreferencesDto> SaveAsync(UserPreferencesDto preferences, CancellationToken ct = default)
{
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
// Not authenticated (login page) — skip save, keep local state only.
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
return preferences;
response.EnsureSuccessStatusCode();
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? preferences;
SetCache(saved);
return saved;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to save user preferences to server.");
throw;
}
}
/// <summary>
/// Updates a single grid layout key without a network read.
/// Uses the in-memory cache for the current state, merges the new layout, then saves.
/// If the cache is empty (cold start), fetches from server first.
/// </summary>
/// <param name="gridKey">Stable grid identifier, e.g. "sender.active-envelopes".</param>
/// <param name="layoutJson">Opaque layout JSON produced by DxGrid.SaveLayoutAsync().</param>
public async Task SaveGridLayoutAsync(string gridKey, string layoutJson, CancellationToken ct = default)
{
// Use cached copy — avoids a GET before every grid layout save.
var prefs = GetCached() ?? await GetAsync(ct);
prefs.GridLayouts[gridKey] = layoutJson;
await SaveAsync(prefs, ct);
}
/// <summary>
/// Invalidates the in-memory cache, forcing the next GetAsync to fetch from the server.
/// Call this after logout or when a forced refresh is needed.
/// </summary>
public void InvalidateCache()
{
_cached = null;
_cachedAt = DateTimeOffset.MinValue;
}
private void SetCache(UserPreferencesDto dto)
{
_cached = dto;
_cachedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -10,6 +10,23 @@
<link rel="stylesheet" href="css/envelope-viewer.css" />
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
<HeadOutlet />
<!-- Apply saved theme immediately on page load — before WASM boots.
Reads localStorage["signflow.theme"] to avoid flash of default theme. -->
<script>
(function () {
var allowed = { 'blazing-dark': true, 'blazing-berry': true, 'purple': true };
var theme = localStorage.getItem('signflow.theme');
if (!theme || !allowed[theme]) theme = 'blazing-berry';
var bsTheme = theme === 'blazing-dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-sf-theme', theme);
document.documentElement.setAttribute('data-bs-theme', bsTheme);
var link = document.createElement('link');
link.id = 'dx-theme-link';
link.rel = 'stylesheet';
link.href = '_content/DevExpress.Blazor.Themes/' + theme + '.bs5.min.css';
document.head.appendChild(link);
})();
</script>
</head>
<body>
@@ -17,6 +34,8 @@
<script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script>
<script src="js/typed.umd.js"></script>
<script src="js/receiver-signature.js?v=9"></script>
<script src="js/signflow-theme.js"></script>
<script src="js/envelope-editor.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>

View File

@@ -372,7 +372,7 @@
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" 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"/>
<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>
</div>
<div>
@@ -391,8 +391,8 @@
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" 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"/>
<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>

View File

@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Models;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Exposes the CustomImages configuration (signFLOW logo + company logo) to WASM clients.
/// Values are sourced from appsettings.json "CustomImages" section via Options Pattern.
///
/// To change logos per deployment: update "CustomImages.App.Src", "App.Height",
/// "Company.Src", "Company.Height" in appsettings.json — no code change required.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class CustomImagesController(IOptions<CustomImagesOptions> options) : ControllerBase
{
/// <summary>
/// Returns the signFLOW app logo and company logo configuration.
/// </summary>
[HttpGet]
public ActionResult<CustomImagesOptions> Get() => Ok(options.Value);
}

View File

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE).
/// Accessible by both Sender and Receiver roles.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class UserPreferencesController(IUserPreferencesService preferencesService) : ControllerBase
{
private static readonly HashSet<string> AllowedThemes =
["blazing-berry", "blazing-dark", "purple"];
/// <summary>
/// Returns the current user's UI preferences.
/// Always returns 200 with defaults if no preferences have been saved yet.
/// DisplayName is populated from JWT claims for senders only.
/// </summary>
[HttpGet]
public async Task<ActionResult<UserPreferencesDto>> Get(CancellationToken ct)
{
bool isReceiver = User.IsReceiver();
var userId = isReceiver ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var prefs = await preferencesService.GetOrCreateAsync(userId, ct);
// Populate DisplayName for sender only (receiver has no given/surname claims)
if (!isReceiver)
prefs.DisplayName = $"{User.GetPrename()} {User.GetName()}".Trim();
return Ok(prefs);
}
/// <summary>
/// Saves (full replace) the current user's UI preferences.
/// </summary>
[HttpPut]
public async Task<ActionResult<UserPreferencesDto>> Put(
[FromBody] UserPreferencesDto dto,
CancellationToken ct)
{
if (!AllowedThemes.Contains(dto.ThemeName))
return BadRequest($"Invalid theme '{dto.ThemeName}'. Allowed: {string.Join(", ", AllowedThemes)}.");
var userId = User.IsReceiver() ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await preferencesService.SaveAsync(userId, dto, ct);
return Ok(dto);
}
}

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.0.1-beta</Version>
<FileVersion>1.0.1.0</FileVersion>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<Version>1.1.0-beta</Version>
<FileVersion>1.1.0.0</FileVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>

View File

@@ -1,4 +1,6 @@
using DigitalData.Auth.Claims;
using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.IdentityModel.JsonWebTokens;
using System.Security.Claims;
@@ -93,4 +95,14 @@ public static class ReceiverClaimExtensions
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
}
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public static bool IsReceiver(this ClaimsPrincipal user)
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}

View File

@@ -1,6 +1,36 @@
namespace EnvelopeGenerator.Server.Models;
public class CustomImages : Dictionary<string, Image>
/// <summary>
/// A single image asset entry — source path and optional per-context CSS classes.
/// Height controls the rendered size in CSS units (e.g. "28px", "2rem").
/// Change Src and Height in appsettings.json per deployment; no code change required.
/// </summary>
public sealed class CustomImageEntry
{
public new Image this[string key] => TryGetValue(key, out var img) && img is not null ? img : new();
public string Src { get; set; } = string.Empty;
/// <summary>
/// CSS height value, e.g. "28px" or "2rem".
/// Allows different-sized logos across deployments without CSS changes.
/// </summary>
public string Height { get; set; } = "28px";
public Dictionary<string, string> Classes { get; set; } = new();
/// <summary>Returns the CSS class for the given context key, or empty string if absent.</summary>
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) && cls is not null ? cls : string.Empty;
}
/// <summary>
/// Bound from appsettings.json "CustomImages" section via Options Pattern.
/// App = signFLOW application logo.
/// Company = customer/company logo (Digital Data GmbH by default).
/// </summary>
public sealed class CustomImagesOptions
{
public const string SectionName = "CustomImages";
public CustomImageEntry App { get; set; } = new();
public CustomImageEntry Company { get; set; } = new();
}

View File

@@ -271,7 +271,12 @@ try
.AddAuthenticationSchemes(AuthScheme.Receiver)
.RequireAuthenticatedUser()
.RequireRole(Role.Receiver.Full, "receiver"))
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA))
// Combined policy: accepts both Sender and Receiver tokens.
// Used by endpoints accessible to both roles (e.g. UserPreferences).
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver)
.RequireAuthenticatedUser());
// User Manager
#pragma warning disable CS0618
@@ -318,6 +323,21 @@ try
.AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
// CustomImages — logo sources and heights, configurable per deployment via appsettings.json
builder.Services.Configure<EnvelopeGenerator.Server.Models.CustomImagesOptions>(
config.GetSection(EnvelopeGenerator.Server.Models.CustomImagesOptions.SectionName));
// Client-side services that MainLayout depends on must also be registered here
// so that Blazor Auto prerender (SSR pass) can resolve them on the server.
// ThemeService.InitializeAsync() uses IJSRuntime but is only called in OnAfterRenderAsync
// (never during prerender), so registering it server-side is safe.
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<AuthService>();

View File

@@ -17,7 +17,7 @@
"http://localhost:7192"
],
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
"Default": "Server=SDD-VHP04-SQL19\\DD_TESTING01;Database=DD_ECM;User Id=sa;Password=123456789dD!;Encrypt=false;TrustServerCertificate=True;",
"DbMigrationTest": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM_DATA_MIGR_TEST;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"DirectorySearchOptions": {
@@ -145,15 +145,17 @@
"CustomImages": {
"App": {
"Src": "/img/DD_signFLOW_LOGO.png",
"Height": "28px",
"Classes": {
"Main": "signFlow-logo"
"Main": "signflow-app-logo"
}
},
"Company": {
"Src": "/img/digital_data.svg",
"Height": "33px",
"Classes": {
"Show": "dd-show-logo",
"Locked": "dd-locked-logo"
"Show": "signflow-company-logo",
"Locked": "signflow-company-logo"
}
}
},

View File

@@ -333,6 +333,24 @@ article {
font-weight: 500;
}
/* ── Logos ───────────────────────────────────────────────────────────────── */
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */
.signflow-app-logo {
width: auto;
object-fit: contain;
display: block;
flex-shrink: 0;
}
.signflow-company-logo {
width: auto;
object-fit: contain;
display: block;
opacity: 0.88;
flex-shrink: 0;
}
/* ── Footer ──────────────────────────────────────────────────────────────── */
.receiver-footer {
position: fixed;
@@ -342,7 +360,7 @@ article {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between; /* ← left group | right group */
gap: 10px;
padding: 0 20px;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
@@ -363,6 +381,224 @@ article {
text-decoration: underline;
}
.receiver-footer__left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.receiver-footer__sep {
opacity: 0.4;
}
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
/* Left group: copyright | datenschutz → Right group: slider */
.sf-theme-slider {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
/* Moon / Sun SVG icons */
.sf-theme-icon {
display: block;
opacity: 0.55; /* ↑ was 0.42 — more visible as base */
flex-shrink: 0;
transition: opacity 0.18s;
align-self: center;
}
.sf-theme-icon--active {
opacity: 0.92;
}
.sf-theme-icon--mid {
opacity: 0.62;
}
/* ── Wrapper holds the range input + tick marks ───────────────────────────── */
.sf-theme-track-wrapper {
position: relative;
display: flex;
align-items: center;
width: 80px;
height: 16px; /* enough height so ticks + thumb don't clip */
}
/* Actual range input — fills the wrapper */
.sf-theme-range {
-webkit-appearance: none;
appearance: none;
position: relative;
z-index: 1;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
outline: none;
cursor: pointer;
margin: 0;
/* no extra padding so ticks align correctly */
}
.sf-theme-range:hover {
background: rgba(255, 255, 255, 0.42);
}
/* Thumb — white circle */
.sf-theme-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
transition: transform 0.12s, box-shadow 0.12s;
}
.sf-theme-range::-webkit-slider-thumb:hover {
transform: scale(1.25);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.38);
}
/* Firefox */
.sf-theme-range::-moz-range-thumb {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
.sf-theme-range::-moz-range-track {
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
}
/* ── End ticks (left & right): thin vertical bars ────────────────────────── */
.sf-theme-tick {
position: absolute;
z-index: 0;
width: 2px;
height: 7px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.45);
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.sf-theme-tick--left { left: 0; }
.sf-theme-tick--right { right: 0; }
/* Mid tick — thinner, subtler */
.sf-theme-tick--mid {
left: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 5px;
background: rgba(255, 255, 255, 0.50); /* ↑ was 0.22 */
}
/* Sun icon: always visible regardless of active state */
.sf-theme-icon--sun {
opacity: 0.72; /* minimum visibility */
}
/* ── Theme overrides: Home page & Login ──────────────────────────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ── */
[data-sf-theme="purple"] .home-page-wrapper {
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
}
[data-sf-theme="purple"] .home-btn-primary {
background: linear-gradient(135deg, #6b21a8 0%, #9333ea 100%);
}
[data-sf-theme="purple"] .home-feature-badge {
background: rgba(147, 51, 234, 0.10);
color: #c084fc;
border-color: rgba(147, 51, 234, 0.25);
}
/* Login card header: set via a CSS rule so inline gradient can be overridden */
.login-card .card-header {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%) !important;
}
[data-sf-theme="purple"] .login-card .card-header {
background: linear-gradient(135deg, #1a0533 0%, #6b21a8 100%) !important;
}
[data-sf-theme="blazing-dark"] .home-page-wrapper {
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .home-btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
}
[data-sf-theme="blazing-dark"] .home-feature-badge {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.28);
}
[data-sf-theme="blazing-dark"] .home-card {
background: rgba(15, 20, 40, 0.92) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .home-card .text-muted {
color: rgba(255, 255, 255, 0.55) !important;
}
[data-sf-theme="blazing-dark"] .login-card {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-header {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-body {
background: rgba(15, 20, 40, 0.95);
}
[data-sf-theme="blazing-dark"] .login-card .card-footer {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.45) !important;
}
[data-sf-theme="blazing-dark"] .login-card .form-label {
color: rgba(255, 255, 255, 0.75);
}
[data-sf-theme="blazing-dark"] .login-card .form-control,
[data-sf-theme="blazing-dark"] .login-card .input-group-text {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
[data-sf-theme="blazing-dark"] .login-card .btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%) !important;
border-color: transparent !important;
}
[data-sf-theme="blazing-dark"] .login-card .text-muted {
color: rgba(255, 255, 255, 0.50) !important;
}

View File

@@ -1103,3 +1103,111 @@ body.resizing {
padding: 1.5rem;
}
}
/* ── Theme overrides: Envelope Viewer / Editor / Receiver ───────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ─────────────────────────────────────────────────────────────── */
[data-sf-theme="purple"] .envelope-viewer-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .envelope-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .envelope-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .pdf-toolbar__btn {
border-color: rgba(147, 51, 234, 0.25);
}
[data-sf-theme="purple"] .pdf-toolbar__btn:hover {
border-color: rgba(147, 51, 234, 0.5);
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.2);
}
[data-sf-theme="purple"] .pdf-text-layer ::selection,
[data-sf-theme="purple"] .pdf-text-layer ::-moz-selection {
background: rgba(147, 51, 234, 0.3);
}
/* ── Blazing Dark ───────────────────────────────────────────────────────── */
[data-sf-theme="blazing-dark"] .envelope-viewer-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .envelope-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .envelope-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .envelope-key {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .envelope-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.82);
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn:hover {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--signature-change-active {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.5);
color: #a5b4fc;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
/* Receiver info header */
[data-sf-theme="blazing-dark"] .receiver-info-header__gradient {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .receiver-info-message {
color: rgba(255, 255, 255, 0.70);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .receiver-info-private-message {
background: rgba(245, 158, 11, 0.08);
border-top-color: rgba(245, 158, 11, 0.18);
color: #fde68a;
}
/* Signature action bar */
[data-sf-theme="blazing-dark"] .receiver-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
/* PDF text selection */
[data-sf-theme="blazing-dark"] .pdf-text-layer ::selection,
[data-sf-theme="blazing-dark"] .pdf-text-layer ::-moz-selection {
background: rgba(99, 102, 241, 0.35);
}

View File

@@ -28,6 +28,34 @@
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0; /* don't shrink — logo area in the middle gets the space */
}
/* ── Customer logo area (middle of action bar) ──────────────────────────── */
.sender-customer-logo-area {
flex: 1; /* takes up remaining space between left and right */
display: flex;
justify-content: center;
align-items: center;
min-width: 0; /* allow flex child to shrink below content size */
}
/* Placeholder shown when no customer logo is configured */
.sf-customer-logo-placeholder {
display: flex;
align-items: center;
height: 28px;
min-width: 90px;
padding: 0 12px;
border: 1px dashed rgba(126, 34, 206, 0.28);
border-radius: 6px;
font-size: 0.65rem;
font-weight: 500;
color: rgba(126, 34, 206, 0.38);
white-space: nowrap;
letter-spacing: 0.03em;
text-transform: uppercase;
user-select: none;
}
.sender-logo svg {
@@ -171,7 +199,20 @@
}
.sender-grid-wrapper {
padding: 1.5rem 2rem 2rem;
padding: 0 2rem 2rem; /* top padding removed — hint banner provides top spacing */
}
/* ── Grid persistence hint banner ─────────────────────────────────────────── */
.sf-grid-persistence-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 2rem;
font-size: 0.72rem;
color: rgba(126, 34, 206, 0.65);
background: rgba(126, 34, 206, 0.04);
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
user-select: none;
}
/* Hide DevExpress empty cells */
@@ -295,3 +336,146 @@
font-size: 0.813rem;
}
}
/* ── Theme overrides ─────────────────────────────────────────────────────── */
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
/* ── Purple theme ──────────────────────────────────────────────────────────
Deeper violet, less blue — visually distinct from Blazing Berry */
[data-sf-theme="purple"] .sender-dashboard-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-grid-container::before {
background: linear-gradient(90deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .sender-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .sender-btn--primary {
background: linear-gradient(135deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #7e22ce 0%, #581c87 100%);
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.35);
}
[data-sf-theme="purple"] .sender-btn:hover:not(:disabled) {
border-color: rgba(147, 51, 234, 0.45);
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
}
[data-sf-theme="purple"] .sender-tabs {
border-bottom-color: rgba(147, 51, 234, 0.12);
}
[data-sf-theme="purple"] .sender-tab--active {
color: #9333ea;
border-bottom-color: #9333ea;
}
/* ── Blazing Dark theme ────────────────────────────────────────────────────
Dark navy background, frosted glass surfaces turn semi-dark */
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .sender-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .sender-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .sender-btn {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-btn:hover:not(:disabled) {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
box-shadow: 0 4px 12px rgba(129, 140, 248, 0.2);
}
[data-sf-theme="blazing-dark"] .sender-btn:disabled {
color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-btn--primary {
background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #818cf8 0%, #6366f1 100%);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-btn--danger {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.28);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .sender-btn--danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-grid-container {
background: rgba(15, 20, 40, 0.95);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
}
[data-sf-theme="blazing-dark"] .sender-tabs {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(99, 102, 241, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-tab {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .sender-tab--active {
color: #818cf8;
border-bottom-color: #818cf8;
}
[data-sf-theme="blazing-dark"] .sender-tab:hover:not(.sender-tab--active) {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.04);
}
/* Error container in dark mode */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 121.32">
<defs>
<style>
.cls-1 {
fill: #a52431;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M83.01,60.66c0-35.12-12.77-57.47-41.51-57.47H0v114.94h39.91c30.33,0,43.1-20.75,43.1-57.47M49.49,60.66c0,19.16-1.6,28.73-15.96,28.73V31.93c14.37,0,15.96,12.77,15.96,28.73M126.11,3.19h-33.52v114.94h33.52V3.19ZM223.49,97.38v-46.29h-39.91l-1.6,23.95h12.77v9.58c-3.19,4.79-6.39,7.98-12.77,7.98-7.98,0-12.77-11.17-12.77-31.93s4.79-31.93,12.77-31.93c6.86,0,11.33,5.75,12.77,14.37l28.73-6.39C218.7,15.96,207.52,0,181.98,0c-33.52,0-46.29,28.73-46.29,60.66s12.77,60.66,46.29,60.66c17.56,0,31.93-7.98,41.51-23.94M268.18,3.19h-33.52v114.94h33.52V3.19ZM346.4,31.93l-3.19-28.74h-65.45l-3.19,28.74h19.16v86.2h33.52V31.93h19.16ZM427.81,118.13L399.07,3.19h-31.93l-28.73,114.94h35.12l1.6-14.37h15.96l1.6,14.37h35.12ZM387.58,76.62h-8.94l3.51-28.74c.32-3.19.64-6.38.8-9.58h.32c.16,3.19.48,6.38.8,9.58l3.51,28.74ZM493.26,89.4h-25.54V3.19h-33.52v114.94h55.87l3.19-28.74ZM614.58,60.66c0-35.12-12.77-57.47-41.51-57.47h-41.51v114.94h39.91c30.33,0,43.1-20.75,43.1-57.47M581.05,60.66c0,19.16-1.6,28.73-15.96,28.73V31.93c14.37,0,15.96,12.77,15.96,28.73M704.75,118.13l-28.73-114.94h-31.93l-28.74,114.94h35.12l1.6-14.37h15.96l1.6,14.37h35.12ZM664.52,76.62h-8.94l3.51-28.74c.32-3.19.64-6.38.8-9.58h.32c.16,3.19.48,6.38.8,9.58l3.51,28.74ZM768.59,31.93l-3.19-28.74h-65.45l-3.19,28.74h19.16v86.2h33.52V31.93h19.16ZM850,118.13l-28.73-114.94h-31.93l-28.73,114.94h35.12l1.6-14.37h15.96l1.6,14.37h35.12ZM809.77,76.62h-8.94l3.51-28.74c.32-3.19.64-6.38.8-9.58h.32c.16,3.19.48,6.38.8,9.58l3.51,28.74Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,37 @@
/**
* signFLOW Theme Helper
* Called from ThemeService (Blazor WASM) to apply theme changes.
*/
window.signflowTheme = {
apply: function (href, bsTheme, themeName) {
console.debug('[signflowTheme] apply', themeName, bsTheme);
// ── Bootstrap dark mode ─────────────────────────────────────────
document.documentElement.setAttribute('data-bs-theme', bsTheme);
// ── Theme name attribute for CSS overrides ──────────────────────
// Applied to <html> so any nested selector can use it:
// [data-sf-theme="blazing-dark"] .sender-dashboard-layout { ... }
document.documentElement.setAttribute('data-sf-theme', themeName);
// ── DevExpress theme CSS link ────────────────────────────────────
document.querySelectorAll('link[href*="DevExpress.Blazor.Themes"]').forEach(function(el) {
if (el.id !== 'dx-theme-link') el.parentNode.removeChild(el);
});
let link = document.getElementById('dx-theme-link');
if (!link) {
link = document.createElement('link');
link.id = 'dx-theme-link';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
const currentRelative = link.getAttribute('href') || '';
if (currentRelative !== href) {
link.setAttribute('href', href);
}
}
};

View File

@@ -0,0 +1,415 @@
# SF-74 — signFLOW: Theme, Dark Mode, Grid Persistence, Logo & Dashboard
**Ticket:** SF-74 | **Bearbeiter:** Hakan Tek | **Status:** In Bearbeitung
**Letzte Aktualisierung:** 2026-09-03
**Erstellt von:** Marlon Schreiber | **Verknüpft mit:** SF-71
---
## Originale Anforderungen
### Marlon Schreiber (Ticket-Text):
1. **signFLOW-Logo** + **Kunden-Logo-Bereich** auf der Startseite
2. **DevExpress Skin-Auswahl** — mindestens 3 Themes, vom User wählbar + gespeichert
3. **Grid-Layout-Persistenz** — Spaltenbreite/Position pro User gespeichert
### Screenshot-Annotationen (`/sender`):
```
[signFLOW Logo] Umschlag-Übersicht [Kunden-Logo] [+Neuer Umschlag][Bearb.][Lösch.][↺][→|]
signFLOW Portal-Übersicht [Zurückrufen/löschen][Dok.anzeigen][Excel]
[Info-Banner: "Die Größe und Position der Spalten muss verändert und für den User gespeichert..."]
ID ↓ Titel Status [Typ] Empfänger [Erstellt] [Geändert am]
```
---
## Projektstruktur — Clean Architecture
```
EnvelopeGenerator.Domain/ → Entities, Domain-Interfaces
EnvelopeGenerator.Application/ → Service-Interfaces, DTOs, Use-Cases
EnvelopeGenerator.Infrastructure/ → Cache, DB, Implementierungen
EnvelopeGenerator.Server/ → Blazor SSR Host, Controller, Middleware
EnvelopeGenerator.Server.Client/ → Blazor WASM Client
```
**Abhängigkeitsregel:** `Domain ← Application ← Infrastructure ← Server / Server.Client`
---
## ✅ Abgeschlossene Phasen (1–5)
| Phase | Inhalt | Status |
|-------|--------|--------|
| 1 | Server: UserPreferences API (TBDD_CACHE, `IUserPreferencesService`) | ✅ |
| 2 | WASM: `ThemeService`, `UserPreferencesService` (1h In-Memory-Cache) | ✅ |
| 3 | Footer Theme Slider (🌙 blazing-dark ── blazing-berry ── purple ☀️) | ✅ |
| 4 | Grid Layout Persistence (`LayoutAutoSaving`/`LayoutAutoLoading`) | ✅ |
| 5 | Theme-CSS-Overrides für alle Seiten (`[data-sf-theme]`) | ✅ |
**Key decisions:** TBDD_CACHE statt eigener DB-Tabelle · `SenderOrReceiver` Policy · Slider statt Toggle · `signflow.theme` in localStorage · DevExpress 25.2.3 hat keine `.dark.`-Suffix-Dateien
---
## 🔲 Phase 6a — signFLOW-Logo
### Ziel
signFLOW-App-Logo im Sender Dashboard (`/sender`) Action Bar links neben dem Titel, sowie auf der Startseite (`/`) im Hero-Header.
### Technischer Ansatz
**Options Pattern** — `appsettings.json` → `CustomImagesOptions` → DI → Controller → WASM Service.
```
appsettings.json
└── CustomImagesOptions (Options Pattern, IOptions<>)
└── GET /api/CustomImages (neuer Controller, auth: SenderOrReceiver)
└── CustomImagesService (WASM, in-memory cache, kein TTL)
└── EnvelopeSenderPage + IndexPage
```
**Warum kein `wwwroot/appsettings.json`?**
Statische Assets sind public (keine Auth). Logo-Pfade sind deployment-spezifisch.
Options Pattern erlaubt Produktionswechsel ohne Code-Änderung.
### Neue/geänderte Dateien
#### `Server/Models/CustomImages.cs` — NEU
```csharp
public sealed class CustomImageEntry
{
public string Src { get; set; } = string.Empty;
/// <summary>CSS height, e.g. "28px" or "2rem". Change per deployment in appsettings.</summary>
public string Height { get; set; } = "28px";
public Dictionary<string, string> Classes { get; set; } = new();
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) ? cls ?? string.Empty : string.Empty;
}
public sealed class CustomImagesOptions
{
public const string SectionName = "CustomImages";
public CustomImageEntry App { get; set; } = new();
public CustomImageEntry Company { get; set; } = new();
}
```
#### `appsettings.json` — Änderung (Zeile 145)
```json
"CustomImages": {
"App": { "Src": "/img/DD_signFLOW_LOGO.png", "Height": "28px",
"Classes": { "Main": "signflow-app-logo" } },
"Company": { "Src": "/img/digital_data.svg", "Height": "22px",
"Classes": { "Show": "signflow-company-logo" } }
}
```
> **Deployment:** Nur `Src` + `Height` in `appsettings.json` ändern — keine Code-Anpassung nötig.
#### `Server/Controllers/CustomImagesController.cs` — NEU
```
GET /api/CustomImages
Auth: [Authorize(Policy = AuthPolicy.SenderOrReceiver)]
Returns: CustomImagesOptions
```
#### `Server/Program.cs` — DI
```csharp
builder.Services.Configure<CustomImagesOptions>(
config.GetSection(CustomImagesOptions.SectionName));
```
#### `Server/wwwroot/img/` — Neue Dateien
Kopiert aus `EnvelopeGenerator.Web/wwwroot/img/`:
- `DD_signFLOW_LOGO.png`
- `digital_data.svg`
#### `Client/Services/CustomImagesService.cs` — NEU
```csharp
// GET /api/CustomImages → einmalig laden, in-memory gecacht (Config ändert sich kaum)
public class CustomImagesService(IHttpClientFactory factory)
{
private CustomImagesDto? _cached;
public async Task<CustomImagesDto> GetAsync(CancellationToken ct = default) { ... }
}
```
#### `app.css` — Neue Klassen
```css
.signflow-app-logo { height: var(--sf-app-height, 28px); width: auto; object-fit: contain; }
.signflow-company-logo { height: var(--sf-company-height, 22px); width: auto; object-fit: contain; opacity: .88; }
```
#### `IndexPage.razor` — Änderung
```razor
<!-- Alt: <svg class="home-hero-header__icon"> -->
<!-- Neu: -->
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
class="home-hero-header__icon" alt="signFLOW" />
```
#### `EnvelopeSenderPage.razor` — signFLOW Logo im Action Bar
```razor
<div class="sender-branding">
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
class="@_appLogo.GetClass("Main")" alt="signFLOW" />
<div class="sender-title-block">
<div class="sender-title">Umschlag-Übersicht</div>
<div class="sender-subtitle">signFLOW Portal - Übersicht</div>
</div>
</div>
```
---
## 🔲 Phase 6b — Kunden-Logo Placeholder
### Ziel
Mittlerer Bereich im Action Bar zeigt das Kunden-Logo (aus Config) oder einen Platzhalter.
### Implementierung
**Config-Quelle:** `CustomImagesOptions.Company` (aus Phase 6a bereits geladen)
```razor
<!-- Mitte des Action Bars -->
<div class="sender-customer-logo-area">
@if (!string.IsNullOrEmpty(_companyLogo?.Src))
{
<img src="@_companyLogo.Src" style="height: @_companyLogo.Height"
class="@_companyLogo.GetClass("Show")" alt="Kunden-Logo" />
}
else
{
<div class="sf-customer-logo-placeholder">Kunden-Logo</div>
}
</div>
```
#### `app.css` — Neue Klasse
```css
.sf-customer-logo-placeholder {
height: 24px; min-width: 90px;
border: 1px dashed rgba(126, 34, 206, 0.3); border-radius: 4px;
display: flex; align-items: center; padding: 0 10px;
font-size: 0.62rem; color: rgba(126, 34, 206, 0.4); white-space: nowrap;
}
```
#### `sender-page.css` — Neues Layout (3-Bereich-Action-Bar)
```css
.sender-branding { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
.sender-subtitle { font-size: .72rem; color: #6b7280; font-weight: 400; }
.sender-customer-logo-area { flex: 1; display: flex; justify-content: center; }
```
---
## 🔲 Phase 6c — Grid: Fehlende Spalten + Persistence Hint
### Fehlende Spalten (aus Screenshot-Annotationen)
**Verfügbarkeit in `EnvelopeDto`:**
- `EnvelopeTypeTitle` (string?) → `spalte für "Typ"` ✅
- `AddedWhen` (DateTime) → `spalte für "erstellt"` ✅
- `ChangedWhen` (DateTime?) → `Spalte "zuletzt geändert am"` ✅
> ⚠️ **Vor Implementierung prüfen:** `EnvelopeTypeTitle` wird aus dem Navigation Property `EnvelopeType` gemappt. Mapping-Konfiguration in `Application/...MappingProfile.cs` verifizieren.
#### `EnvelopeSenderPage.razor` — Neue Spalten (beide Grids)
```razor
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
```
### Grid Persistence Hint
Info-Banner über dem Grid:
```razor
<div class="sf-grid-persistence-hint">
<svg><!-- info icon --></svg>
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
</div>
```
```css
.sf-grid-persistence-hint {
display: flex; align-items: center; gap: 6px;
padding: 5px 16px; font-size: 0.72rem;
color: rgba(126, 34, 206, 0.7);
background: rgba(126, 34, 206, 0.04);
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
}
```
---
## 🔲 Phase 6d — Dokument anzeigen (PDF Preview)
### Anforderung
Ausgewähltes Envelope-Dokument im Popup anzeigen. Envelope ist nach dem Senden **read-only** (Mails wurden gesendet — nachträgliche Änderungen würden Konsistenz brechen).
### Technischer Ansatz
`DxPdfViewer` erfordert **zwingend `@rendermode InteractiveServer`**.
`EnvelopeSenderPage` ist WASM (`@rendermode InteractiveWebAssembly`).
**Lösung:** `DxPopup` im WASM-Parent mit einem Server-Component darin:
```
EnvelopeSenderPage.razor (@rendermode WASM)
└── <DxPopup>
└── <EnvelopePdfPreviewComponent @rendermode InteractiveServer>
└── <DxPdfViewer> (SSR-only)
```
#### `Server/Components/Shared/EnvelopePdfPreviewComponent.razor` — NEU
```razor
@rendermode InteractiveServer
@inject IDocumentService DocumentService <!-- oder HTTP client -->
<DxPdfViewer DocumentContent="@_pdfBytes" />
@code {
[Parameter] public int EnvelopeId { get; set; }
byte[]? _pdfBytes;
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId > 0)
_pdfBytes = await DocumentService.GetPdfBytesAsync(EnvelopeId);
}
}
```
Button: nur aktiv wenn Envelope ausgewählt UND Dokument vorhanden (`envelope.Documents?.Any() == true`).
---
## 🔲 Phase 6e — Export nach Excel
### Technischer Ansatz
`DxGrid.ExportToXlsxAsync(fileName, options)` — direkt im Razor-Code, keine Backend-Implementierung nötig.
Download geht direkt an den Browser.
```csharp
async Task ExportToExcelAsync()
{
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
if (grid is null) return;
var fileName = $"Umschlaege_{DateTime.Now:yyyy-MM-dd}.xlsx";
await grid.ExportToXlsxAsync(fileName, new GridXlExportOptions());
}
```
> **Hinweis:** `CellDisplayTemplate`-Inhalte werden nicht exportiert (DevExpress-Limitierung).
> Die Spalten `Status`, `EnvelopeReceivers` nutzen Templates → Wert im Export ist leer.
> **Future Task:** Für vollständigen Export mit formatierten Zellen `CustomizeCell`-Event nutzen.
Button in Toolbar:
```razor
<button class="sender-btn" @onclick="ExportToExcelAsync"
title="Aktuelle Ansicht als Excel exportieren">
<!-- Excel-Icon SVG -->
Export nach Excel
</button>
```
---
## 🔲 Phase 6f — Action Bar Redesign
### Ziel-Layout (aus Screenshot)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [Logo] Umschlag-Übersicht │ [Kunden-Logo] │ [+Neu] [Bearb.] [Lösch.] │
│ signFLOW Portal-Übers│ │ [↺] [→|] │
│ │ │ [Zurückrufen?] [Dok.] [XLS] │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Neue CSS-Klassen in `sender-page.css`
```css
.sender-toolbar-right { display: flex; flex-direction: column; gap: .35rem; align-items: flex-end; }
.sender-toolbar-row { display: flex; align-items: center; gap: .5rem; }
.sender-toolbar-row--primary { /* Hauptbuttons */ }
.sender-toolbar-row--secondary { /* Dokument anzeigen, Export */ }
```
### `Zurückrufen/löschen` — Offene Frage ❓
> **Ticket-Kommentar-Entwurf (Deutsch, für Marlon Schreiber):**
>
> Hallo @Marlon Schreiber,
>
> im Screenshot sehe ich einen Button „Zurückrufen/löschen". Könntest du den
> Unterschied zum bereits vorhandenen „Löschen"-Button erläutern?
>
> Meine Vermutung: „Zurückrufen" bedeutet das Widerrufen eines **bereits gesendeten**
> Umschlags (Status → `EnvelopeWithdrawn`), während „Löschen" einen noch nicht
> gesendeten Umschlag entfernt. Ist das korrekt? Und falls ja:
> - Sollen Empfänger bei einem Widerruf automatisch benachrichtigt werden?
> - Soll der Status auf `Zurückgerufen` gesetzt werden und der Umschlag
> weiterhin in der Liste erscheinen (aber inaktiv)?
>
> Bitte kurz antworten, damit ich dies korrekt implementieren kann.
---
## Implementierungsreihenfolge Phase 6
```
6a (Logo) → 6b (Kunden-Logo) → 6c (Grid-Spalten) → 6d (PDF Preview) → 6e (Excel) → 6f (Action Bar)
```
Jede Teilphase ist unabhängig deploybar. `6f` fasst alle Layout-Änderungen zusammen
und sollte zuletzt implementiert werden, wenn alle Komponenten fertig sind.
---
## Bekannte Einschränkungen & Offene Punkte
| # | Thema | Status |
|---|-------|--------|
| 1 | `DxPdfViewer` → InteractiveServer zwingend | Architektonisch gelöst (6d) |
| 2 | Excel-Export: Template-Spalten leer im Export | Akzeptiert, Future Task (CustomizeCell) |
| 3 | `Zurückrufen/löschen` — Bedeutung unklar | Kommentar-Frage vorbereitet (6f) |
| 4 | `EnvelopeTypeTitle` Mapping prüfen | Vor 6c verifizieren |
| 5 | Logo-Pfade produktionsspezifisch | Via `appsettings.json` konfigurierbar |
| 6 | Server-seitige Pages — inline Farben bleiben | Out-of-scope |
---
## Nicht in diesem Ticket
- `Einstellungen`-Seite (separates Ticket)
- Mobile Responsive (separates Ticket)
- Excel CustomizeCell vollständige Implementierung (Future Task)
- Widerruf-Backend (wartet auf Klärung)
---
*Erstellt: 2026-09-01 | Aktualisiert: 2026-09-03 | Ticket: SF-74 | Projekt: signFLOW*

View File

@@ -1,5 +1,5 @@
@echo off
setlocal
setlocal enabledelayedexpansion
echo ============================================================
echo EnvelopeGenerator.Server - Self-Contained Publish
@@ -10,11 +10,38 @@ echo.
REM Must be run from the solution root directory.
REM This file is located under: EnvelopeGenerator.Server\
set PROJECT=EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj
set OUTPUT=publish-output
set PROJECT=%~dp0EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj
set RID=win-x64
set FRAMEWORK=net8.0
REM ── Resolve project version from .csproj ──────────────────────────────────────
REM Reads the <Version> element directly from the .csproj XML using findstr.
REM Example: <Version>1.1.0-beta</Version> → VERSION=1.1.0-beta
REM 'tokens=2 delims=>' splits on '>' and takes the second token (the value+tag),
REM then 'delims=<' strips the closing '<' tag leaving the bare version string.
for /f "tokens=2 delims=>< " %%v in ('findstr /i "<Version>" "%PROJECT%"') do (
set VERSION=%%v
)
if "%VERSION%"=="" (
echo [ERROR] Could not read ^<Version^> from %PROJECT%. Aborting.
pause
exit /b 1
)
REM ── Output path: network share / versioned subfolder ──────────────────────────
REM Format: M:\App&Service\0 DD - Smart UP\signFLOW\Server\net8\{version}
REM Note: the '&' in the path is literal — no escaping needed inside a set "..." assignment.
set "OUTPUT=M:\App&Service\0 DD - Smart UP\signFLOW\Server\net8\%VERSION%"
echo [0/3] Resolved build parameters
echo Project : %PROJECT%
echo Version : %VERSION%
echo Output : %OUTPUT%
echo RID : %RID%
echo Framework: %FRAMEWORK%
echo.
echo [1/3] Cleaning previous publish output...
if exist "%OUTPUT%" (
rmdir /s /q "%OUTPUT%"
@@ -25,10 +52,6 @@ if exist "%OUTPUT%" (
echo.
echo [2/3] Publishing...
echo Project : %PROJECT%
echo Output : %OUTPUT%
echo RID : %RID%
echo Framework: %FRAMEWORK%
echo.
dotnet publish "%PROJECT%" ^
@@ -50,36 +73,37 @@ echo [3/3] Verifying output...
set PASS=1
if not exist "%OUTPUT%\EnvelopeGenerator.Server.exe" (
if not exist "!OUTPUT!\EnvelopeGenerator.Server.exe" (
echo [FAIL] EnvelopeGenerator.Server.exe not found!
set PASS=0
)
if not exist "%OUTPUT%\hostfxr.dll" (
) else ( echo [OK] EnvelopeGenerator.Server.exe )
if not exist "!OUTPUT!\hostfxr.dll" (
echo [FAIL] hostfxr.dll not found! (Not a self-contained publish?)
set PASS=0
)
if not exist "%OUTPUT%\coreclr.dll" (
) else ( echo [OK] hostfxr.dll )
if not exist "!OUTPUT!\coreclr.dll" (
echo [FAIL] coreclr.dll not found! (Not a self-contained publish?)
set PASS=0
)
if not exist "%OUTPUT%\Microsoft.Extensions.DependencyInjection.Abstractions.dll" (
) else ( echo [OK] coreclr.dll )
if not exist "!OUTPUT!\Microsoft.Extensions.DependencyInjection.Abstractions.dll" (
echo [FAIL] Microsoft.Extensions.DependencyInjection.Abstractions.dll not found!
set PASS=0
)
if not exist "%OUTPUT%\web.config" (
) else ( echo [OK] Microsoft.Extensions.DependencyInjection.Abstractions.dll )
if not exist "!OUTPUT!\web.config" (
echo [FAIL] web.config not found!
set PASS=0
)
) else ( echo [OK] web.config )
if "%PASS%"=="1" (
if "!PASS!"=="1" (
echo.
echo ============================================================
echo PUBLISH SUCCEEDED
echo Output folder: %~dp0%OUTPUT%
echo Version : !VERSION!
echo Output folder: !OUTPUT!
echo ============================================================
echo.
echo Next steps:
echo 1. Copy the contents of '%OUTPUT%' to the IIS application directory
echo 1. Copy the contents of the output folder to the IIS application directory
echo 2. Set the IIS Application Pool to 'No Managed Code'
echo 3. Recycle the Application Pool
echo.

View File

@@ -273,7 +273,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
ctx.moveTo(30 * scale, 50 * scale)
ctx.lineTo(signatureLength, 50 * scale)
ctx.strokeStyle = 'darkgray'
ctx.strokeStyle = 'darkblue'
ctx.stroke()
ctx.fillStyle = 'black'