Add admin access and landing dashboard
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IAdminAccessService AdminAccess
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||||
|
@T("Adminbereich ist geschützt. Bitte anmelden.", "Admin area is protected. Please sign in.")
|
||||||
|
</MudAlert>
|
||||||
|
@if (!AdminAccess.IsConfigured)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||||
|
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Unlock"
|
||||||
|
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!AdminAccess.IsConfigured)">
|
||||||
|
@T("Admin entsperren", "Unlock admin")
|
||||||
|
</MudButton>
|
||||||
|
<MudDivider />
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
|
<MudStack Spacing="3" Class="pt-2">
|
||||||
|
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePassword"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!AdminAccess.IsConfigured)">
|
||||||
|
@T("Passwort speichern", "Save password")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _username;
|
||||||
|
private string? _password;
|
||||||
|
private string? _changeUsername;
|
||||||
|
private string? _currentPassword;
|
||||||
|
private string? _newPassword;
|
||||||
|
private string? _newPasswordRepeat;
|
||||||
|
|
||||||
|
private void Unlock()
|
||||||
|
{
|
||||||
|
if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_password = string.Empty;
|
||||||
|
OnUnlocked.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChangePassword()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_newPassword != _newPasswordRepeat)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdminAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPassword = string.Empty;
|
||||||
|
_newPassword = string.Empty;
|
||||||
|
_newPasswordRepeat = string.Empty;
|
||||||
|
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnUnlocked { get; set; }
|
||||||
|
}
|
||||||
@@ -23,12 +23,31 @@
|
|||||||
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
|
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
|
||||||
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
|
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
<MudDivider />
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
|
<MudStack Spacing="3" Class="pt-2">
|
||||||
|
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePasswordAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!FinanceAccess.IsConfigured)">
|
||||||
|
@T("Passwort speichern", "Save password")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string? _username;
|
private string? _username;
|
||||||
private string? _password;
|
private string? _password;
|
||||||
|
private string? _changeUsername;
|
||||||
|
private string? _currentPassword;
|
||||||
|
private string? _newPassword;
|
||||||
|
private string? _newPasswordRepeat;
|
||||||
|
|
||||||
private Task UnlockAsync()
|
private Task UnlockAsync()
|
||||||
{
|
{
|
||||||
@@ -43,5 +62,32 @@
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task ChangePasswordAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_newPassword != _newPasswordRepeat)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FinanceAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPassword = string.Empty;
|
||||||
|
_newPassword = string.Empty;
|
||||||
|
_newPasswordRepeat = string.Empty;
|
||||||
|
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private string T(string german, string english) => UiText.Text(german, english);
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
|
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
|
||||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
|
<MudNavLink Href="/export-dashboard" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
@T("Export Dashboard", "Export dashboard")
|
@T("Export Dashboard", "Export dashboard")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
||||||
@@ -62,6 +62,9 @@
|
|||||||
@T("HR KPI Schulung", "HR KPI training")
|
@T("HR KPI Schulung", "HR KPI training")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
|
<MudNavLink Href="/admin/sessions" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PeopleAlt">
|
||||||
|
@T("Admin Bereich", "Admin area")
|
||||||
|
</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@page "/admin/sessions"
|
||||||
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IAccessSessionTracker SessionTracker
|
||||||
|
@inject IAdminAccessService AdminAccess
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Aktive Logins", "Active logins")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Aktive Logins", "Active logins")</MudText>
|
||||||
|
|
||||||
|
@if (!AdminAccess.IsUnlocked)
|
||||||
|
{
|
||||||
|
<AdminAccessPanel OnUnlocked="Refresh" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">@T("HR-/Finance-Cockpit Sessions", "HR/Finance cockpit sessions")</MudText>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Gezählt werden App-interne Entsperrungen seit dem letzten App-Start.", "Counts app-internal unlocks since the last app start.")
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Lock" OnClick="LockAdmin">
|
||||||
|
@T("Admin sperren", "Lock admin")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="Refresh">
|
||||||
|
@T("Aktualisieren", "Refresh")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable Items="_sessions" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Bereich", "Area")</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("IP-Adresse", "IP address")</MudTh>
|
||||||
|
<MudTh>@T("Entsperrt seit", "Unlocked since")</MudTh>
|
||||||
|
<MudTh>@T("Zuletzt gesehen", "Last seen")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Area</MudTd>
|
||||||
|
<MudTd>@context.Username</MudTd>
|
||||||
|
<MudTd>@context.RemoteAddress</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.StartedAt)</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.LastSeenAt)</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine aktiven HR-/Finance-Logins erfasst.", "No active HR/Finance logins recorded.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
|
@T("Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person.", "Note: HR and Finance use shared app logins. This page therefore shows the used login name and session, not necessarily the real person.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<AccessSessionSnapshot> _sessions = [];
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Refresh()
|
||||||
|
{
|
||||||
|
_sessions = SessionTracker.GetActiveSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LockAdmin()
|
||||||
|
{
|
||||||
|
AdminAccess.Lock();
|
||||||
|
_sessions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(DateTimeOffset value)
|
||||||
|
=> value.ToString("dd.MM.yyyy HH:mm:ss");
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -1,481 +1,126 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@using System.Diagnostics
|
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IDashboardPageService DashboardPageActions
|
|
||||||
@inject ExportOrchestrationService Orchestrator
|
|
||||||
@inject TimerBackgroundService TimerService
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject IUiTextService UiText
|
@inject IUiTextService UiText
|
||||||
@implements IDisposable
|
|
||||||
|
|
||||||
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
|
<PageTitle>@T("Trafag Cockpit", "Trafag Cockpit")</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
|
<div class="home-shell">
|
||||||
|
<div class="home-content">
|
||||||
|
<svg class="home-manometer" viewBox="0 0 600 340" role="img" aria-label="Trafag cockpit manometer">
|
||||||
|
<rect x="0" y="0" width="600" height="340" fill="#fff" />
|
||||||
|
<path d="M70 260 A230 230 0 0 1 530 260" class="gauge-outer" />
|
||||||
|
<path d="M115 260 A185 185 0 0 1 485 260" class="gauge-inner" />
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
<line x1="126" y1="260" x2="95" y2="260" class="gauge-tick" />
|
||||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
|
<line x1="177" y1="137" x2="155" y2="115" class="gauge-tick" />
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
|
<line x1="300" y1="86" x2="300" y2="55" class="gauge-tick" />
|
||||||
OnClick="ExportAll" Disabled="_anyRunning">
|
<line x1="423" y1="137" x2="445" y2="115" class="gauge-tick" />
|
||||||
@T("Alle exportieren", "Export all")
|
<line x1="474" y1="260" x2="505" y2="260" class="gauge-tick" />
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
|
|
||||||
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
|
|
||||||
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
|
|
||||||
</MudButton>
|
|
||||||
<MudText Typo="Typo.body1">
|
|
||||||
@if (TimerService.NextRun < DateTime.MaxValue)
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
|
|
||||||
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
|
|
||||||
@T("Timer deaktiviert", "Timer disabled")
|
|
||||||
}
|
|
||||||
</MudText>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
@if (_readinessWarnings.Count > 0)
|
<text x="150" y="230" class="gauge-label">0</text>
|
||||||
{
|
<text x="205" y="154" class="gauge-label">25</text>
|
||||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4">
|
<text x="300" y="126" class="gauge-label">50</text>
|
||||||
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText>
|
<text x="395" y="154" class="gauge-label">75</text>
|
||||||
@foreach (var warning in _readinessWarnings)
|
<text x="450" y="230" class="gauge-label">100</text>
|
||||||
{
|
<text x="300" y="222" class="gauge-brand">TRAFAG</text>
|
||||||
<MudText Typo="Typo.caption">@warning</MudText>
|
|
||||||
}
|
|
||||||
</MudAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (_consolidatedStale)
|
<g class="gauge-needle">
|
||||||
{
|
<line x1="300" y1="260" x2="300" y2="96" class="needle-line" />
|
||||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
|
</g>
|
||||||
@T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.",
|
<circle cx="300" cy="260" r="28" fill="#050505" />
|
||||||
"At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.")
|
</svg>
|
||||||
</MudAlert>
|
<div class="home-welcome">@T("Willkommen im Trafag Analyse Dashboard", "Welcome to the Trafag Analytical Dashboard")</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
<style>
|
||||||
<HeaderContent>
|
.home-shell {
|
||||||
<MudTh>@T("Land", "Country")</MudTh>
|
min-height: calc(100vh - 112px);
|
||||||
<MudTh>@T("Basis", "Basis")</MudTh>
|
display: flex;
|
||||||
<MudTh>TSC</MudTh>
|
align-items: center;
|
||||||
<MudTh>@T("Schema", "Schema")</MudTh>
|
justify-content: center;
|
||||||
<MudTh>@T("Server", "Server")</MudTh>
|
background: #fff;
|
||||||
<MudTh>@T("Status", "Status")</MudTh>
|
|
||||||
<MudTh>@T("Live-Status", "Live status")</MudTh>
|
|
||||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
|
||||||
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
|
|
||||||
<MudTh>@T("Dauer", "Duration")</MudTh>
|
|
||||||
<MudTh>@T("Aktion", "Action")</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd>@context.Land</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
<MudTooltip Text="@context.DataBasis">
|
|
||||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
|
||||||
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
|
|
||||||
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
|
|
||||||
</MudStack>
|
|
||||||
</MudTooltip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd>@context.TSC</MudTd>
|
|
||||||
<MudTd>@context.Schema</MudTd>
|
|
||||||
<MudTd>@context.ServerName</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
@if (Orchestrator.IsExporting(context.SiteId))
|
|
||||||
{
|
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
|
||||||
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
|
|
||||||
}
|
|
||||||
else if (context.LastStatus == "OK")
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
|
||||||
}
|
|
||||||
else if (context.LastStatus == "Error")
|
|
||||||
{
|
|
||||||
<MudTooltip Text="@context.ErrorMessage">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
|
|
||||||
</MudTooltip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
|
|
||||||
{
|
|
||||||
<MudTooltip Text="@context.LiveDetails">
|
|
||||||
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
|
||||||
@context.LiveMessage
|
|
||||||
</MudText>
|
|
||||||
</MudTooltip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
|
||||||
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
|
||||||
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
<MudStack Row Spacing="1">
|
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.FileDownload"
|
|
||||||
OnClick="() => ExportSingle(context.SiteId)"
|
|
||||||
Disabled="Orchestrator.IsExporting(context.SiteId)">
|
|
||||||
Export
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
|
||||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
|
||||||
OnClick="() => OpenExportFile(context)"
|
|
||||||
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
|
|
||||||
@T("Excel oeffnen", "Open Excel")
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
|
|
||||||
<MudTable Items="_consolidatedRows" Dense Hover Striped>
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>@T("Datei", "File")</MudTh>
|
|
||||||
<MudTh>Pfad</MudTh>
|
|
||||||
<MudTh>Letzte Änderung</MudTh>
|
|
||||||
<MudTh>@T("Status", "Status")</MudTh>
|
|
||||||
<MudTh>@T("Aktion", "Action")</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd>@context.Label</MudTd>
|
|
||||||
<MudTd>@context.DisplayPath</MudTd>
|
|
||||||
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
@if (Orchestrator.IsConsolidatedExporting())
|
|
||||||
{
|
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
|
||||||
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
|
||||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
|
||||||
OnClick="() => OpenFile(context.FilePath)"
|
|
||||||
Disabled="@(!context.HasOpenableFile)">
|
|
||||||
@T("Excel oeffnen", "Open Excel")
|
|
||||||
</MudButton>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
<NoRecordsContent>
|
|
||||||
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
|
|
||||||
</NoRecordsContent>
|
|
||||||
</MudTable>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<DashboardRow> _dashboardRows = new();
|
|
||||||
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
|
|
||||||
private List<string> _readinessWarnings = new();
|
|
||||||
private bool _consolidatedStale;
|
|
||||||
private bool _loading = true;
|
|
||||||
private bool _anyRunning;
|
|
||||||
private CancellationTokenSource? _pollingCts;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadDataAsync()
|
.home-content {
|
||||||
{
|
display: flex;
|
||||||
_loading = true;
|
flex-direction: column;
|
||||||
var state = await DashboardPageActions.LoadAsync();
|
align-items: center;
|
||||||
_dashboardRows = state.DashboardRows;
|
gap: 18px;
|
||||||
_consolidatedRows = state.ConsolidatedRows;
|
|
||||||
_readinessWarnings = state.ReadinessWarnings;
|
|
||||||
_consolidatedStale = state.IsConsolidatedStale;
|
|
||||||
|
|
||||||
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
|
||||||
_loading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExportAll()
|
.home-manometer {
|
||||||
{
|
width: min(336px, 58vw);
|
||||||
if (_readinessWarnings.Count > 0)
|
height: auto;
|
||||||
{
|
display: block;
|
||||||
Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.",
|
|
||||||
"There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
_anyRunning = true;
|
|
||||||
await LoadDataAsync();
|
|
||||||
StartPolling();
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Orchestrator.ExportAllAsync();
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
await LoadDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExportConsolidatedOnly()
|
.home-welcome {
|
||||||
{
|
color: #050505;
|
||||||
_anyRunning = true;
|
font-size: 24px;
|
||||||
await LoadDataAsync();
|
font-weight: 700;
|
||||||
StartPolling();
|
text-align: center;
|
||||||
_ = Task.Run(async () =>
|
letter-spacing: 0;
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filePath))
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
await LoadDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ExportSingle(int siteId)
|
.gauge-outer,
|
||||||
{
|
.gauge-inner,
|
||||||
_anyRunning = true;
|
.gauge-tick,
|
||||||
_ = InvokeAsync(async () => await LoadDataAsync());
|
.needle-line {
|
||||||
StartPolling();
|
fill: none;
|
||||||
_ = Task.Run(async () =>
|
stroke: #050505;
|
||||||
{
|
stroke-linecap: round;
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
|
||||||
|
|
||||||
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.",
|
|
||||||
"The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info));
|
|
||||||
}
|
|
||||||
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
await LoadDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void HandleStatusChanged()
|
.gauge-outer {
|
||||||
{
|
stroke-width: 16;
|
||||||
await InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
|
|
||||||
if (_anyRunning)
|
|
||||||
{
|
|
||||||
StartPolling();
|
|
||||||
await RefreshLiveDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StopPolling();
|
|
||||||
await LoadDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
.gauge-inner {
|
||||||
{
|
stroke-width: 4;
|
||||||
StopPolling();
|
|
||||||
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenExportFile(DashboardRow row)
|
.gauge-tick {
|
||||||
{
|
stroke-width: 7;
|
||||||
OpenFile(row.FilePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenFile(string filePath)
|
.gauge-label {
|
||||||
{
|
fill: #050505;
|
||||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
font-size: 24px;
|
||||||
{
|
font-weight: 800;
|
||||||
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
|
text-anchor: middle;
|
||||||
return;
|
dominant-baseline: middle;
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = filePath,
|
|
||||||
UseShellExecute = true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartPolling()
|
.gauge-brand {
|
||||||
{
|
fill: #050505;
|
||||||
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
|
font-size: 28px;
|
||||||
return;
|
font-weight: 900;
|
||||||
|
letter-spacing: 4px;
|
||||||
_pollingCts = new CancellationTokenSource();
|
text-anchor: middle;
|
||||||
_ = PollDashboardAsync(_pollingCts.Token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopPolling()
|
.needle-line {
|
||||||
{
|
stroke-width: 9;
|
||||||
_pollingCts?.Cancel();
|
|
||||||
_pollingCts?.Dispose();
|
|
||||||
_pollingCts = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PollDashboardAsync(CancellationToken cancellationToken)
|
.gauge-needle {
|
||||||
{
|
transform-origin: 300px 260px;
|
||||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
|
animation: home-gauge-sweep 6.2s infinite cubic-bezier(.42, 0, .2, 1);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
|
||||||
if (!anyRunning)
|
|
||||||
{
|
|
||||||
await InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
_anyRunning = false;
|
|
||||||
await LoadDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
StopPolling();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
_anyRunning = true;
|
|
||||||
await RefreshLiveDataAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task RefreshLiveDataAsync()
|
@@keyframes home-gauge-sweep {
|
||||||
{
|
0% { transform: rotate(-58deg); }
|
||||||
foreach (var row in _dashboardRows)
|
9% { transform: rotate(-12deg); }
|
||||||
{
|
18% { transform: rotate(43deg); }
|
||||||
if (!Orchestrator.IsExporting(row.SiteId))
|
31% { transform: rotate(8deg); }
|
||||||
continue;
|
44% { transform: rotate(68deg); }
|
||||||
|
58% { transform: rotate(-35deg); }
|
||||||
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
|
72% { transform: rotate(24deg); }
|
||||||
row.LiveDetails = string.Empty;
|
86% { transform: rotate(56deg); }
|
||||||
}
|
100% { transform: rotate(-58deg); }
|
||||||
|
|
||||||
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
private static string FormatException(Exception ex)
|
|
||||||
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
|
|
||||||
|
|
||||||
private static string GetDataBasisIcon(string dataBasis)
|
|
||||||
{
|
|
||||||
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Icons.Material.Filled.TableView;
|
|
||||||
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Icons.Material.Filled.Description;
|
|
||||||
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Icons.Material.Filled.CloudSync;
|
|
||||||
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Icons.Material.Filled.Storage;
|
|
||||||
|
|
||||||
return Icons.Material.Filled.Source;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Color GetDataBasisColor(string dataBasis)
|
|
||||||
{
|
|
||||||
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Color.Success;
|
|
||||||
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Color.Info;
|
|
||||||
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Color.Primary;
|
|
||||||
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Color.Secondary;
|
|
||||||
|
|
||||||
return Color.Default;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string T(string german, string english) => UiText.Text(german, english);
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
@page "/export-dashboard"
|
||||||
|
@using System.Diagnostics
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IDashboardPageService DashboardPageActions
|
||||||
|
@inject ExportOrchestrationService Orchestrator
|
||||||
|
@inject TimerBackgroundService TimerService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="dashboard-actions">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||||
|
OnClick="ExportAll" Disabled="_anyRunning">
|
||||||
|
@T("Alle exportieren", "Export all")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
|
||||||
|
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
|
||||||
|
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
|
||||||
|
</MudButton>
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
@if (TimerService.NextRun < DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
|
||||||
|
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
|
||||||
|
@T("Timer deaktiviert", "Timer disabled")
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
<div class="dashboard-manometer" aria-label="Export activity manometer">
|
||||||
|
<div class="manometer-arc">
|
||||||
|
<span class="tick tick-0"></span>
|
||||||
|
<span class="tick tick-1"></span>
|
||||||
|
<span class="tick tick-2"></span>
|
||||||
|
<span class="tick tick-3"></span>
|
||||||
|
<span class="tick tick-4"></span>
|
||||||
|
<span class="needle"></span>
|
||||||
|
<span class="hub"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_readinessWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4">
|
||||||
|
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText>
|
||||||
|
@foreach (var warning in _readinessWarnings)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption">@warning</MudText>
|
||||||
|
}
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_consolidatedStale)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
|
||||||
|
@T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.",
|
||||||
|
"At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Basis", "Basis")</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>@T("Schema", "Schema")</MudTh>
|
||||||
|
<MudTh>@T("Server", "Server")</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Live-Status", "Live status")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
|
||||||
|
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTooltip Text="@context.DataBasis">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||||
|
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
|
||||||
|
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd>@context.Schema</MudTd>
|
||||||
|
<MudTd>@context.ServerName</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (Orchestrator.IsExporting(context.SiteId))
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
||||||
|
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
|
||||||
|
}
|
||||||
|
else if (context.LastStatus == "OK")
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else if (context.LastStatus == "Error")
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.ErrorMessage">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.LiveDetails">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.LiveMessage
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
||||||
|
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
|
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudStack Row Spacing="1">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.FileDownload"
|
||||||
|
OnClick="() => ExportSingle(context.SiteId)"
|
||||||
|
Disabled="Orchestrator.IsExporting(context.SiteId)">
|
||||||
|
Export
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenExportFile(context)"
|
||||||
|
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
|
||||||
|
@T("Excel oeffnen", "Open Excel")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
|
||||||
|
<MudTable Items="_consolidatedRows" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Datei", "File")</MudTh>
|
||||||
|
<MudTh>Pfad</MudTh>
|
||||||
|
<MudTh>Letzte Änderung</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.DisplayPath</MudTd>
|
||||||
|
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (Orchestrator.IsConsolidatedExporting())
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
||||||
|
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenFile(context.FilePath)"
|
||||||
|
Disabled="@(!context.HasOpenableFile)">
|
||||||
|
@T("Excel oeffnen", "Open Excel")
|
||||||
|
</MudButton>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-actions {
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-manometer {
|
||||||
|
justify-self: end;
|
||||||
|
width: 210px;
|
||||||
|
height: 118px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #111;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-arc {
|
||||||
|
position: relative;
|
||||||
|
width: 170px;
|
||||||
|
height: 86px;
|
||||||
|
border: 8px solid #111;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 170px 170px 0 0;
|
||||||
|
background: #fff;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-arc::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 70px;
|
||||||
|
border: 2px solid #111;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 140px 140px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: 16px;
|
||||||
|
background: #111;
|
||||||
|
transform-origin: 50% 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-0 { transform: translateX(-50%) rotate(-70deg); }
|
||||||
|
.tick-1 { transform: translateX(-50%) rotate(-35deg); }
|
||||||
|
.tick-2 { transform: translateX(-50%) rotate(0deg); }
|
||||||
|
.tick-3 { transform: translateX(-50%) rotate(35deg); }
|
||||||
|
.tick-4 { transform: translateX(-50%) rotate(70deg); }
|
||||||
|
|
||||||
|
.needle {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 72px;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 4px;
|
||||||
|
transform-origin: 50% 100%;
|
||||||
|
animation: manometer-sweep 5.8s infinite cubic-bezier(.45, 0, .25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -8px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #111;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes manometer-sweep {
|
||||||
|
0% { transform: translateX(-50%) rotate(-52deg); }
|
||||||
|
11% { transform: translateX(-50%) rotate(18deg); }
|
||||||
|
19% { transform: translateX(-50%) rotate(-8deg); }
|
||||||
|
33% { transform: translateX(-50%) rotate(63deg); }
|
||||||
|
48% { transform: translateX(-50%) rotate(4deg); }
|
||||||
|
61% { transform: translateX(-50%) rotate(38deg); }
|
||||||
|
74% { transform: translateX(-50%) rotate(-41deg); }
|
||||||
|
88% { transform: translateX(-50%) rotate(55deg); }
|
||||||
|
100% { transform: translateX(-50%) rotate(-52deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 900px) {
|
||||||
|
.dashboard-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-manometer {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<DashboardRow> _dashboardRows = new();
|
||||||
|
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
|
||||||
|
private List<string> _readinessWarnings = new();
|
||||||
|
private bool _consolidatedStale;
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _anyRunning;
|
||||||
|
private CancellationTokenSource? _pollingCts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
var state = await DashboardPageActions.LoadAsync();
|
||||||
|
_dashboardRows = state.DashboardRows;
|
||||||
|
_consolidatedRows = state.ConsolidatedRows;
|
||||||
|
_readinessWarnings = state.ReadinessWarnings;
|
||||||
|
_consolidatedStale = state.IsConsolidatedStale;
|
||||||
|
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportAll()
|
||||||
|
{
|
||||||
|
if (_readinessWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.",
|
||||||
|
"There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Orchestrator.ExportAllAsync();
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportConsolidatedOnly()
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filePath))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportSingle(int siteId)
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
_ = InvokeAsync(async () => await LoadDataAsync());
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
||||||
|
|
||||||
|
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.",
|
||||||
|
"The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info));
|
||||||
|
}
|
||||||
|
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void HandleStatusChanged()
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
|
||||||
|
if (_anyRunning)
|
||||||
|
{
|
||||||
|
StartPolling();
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StopPolling();
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
StopPolling();
|
||||||
|
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenExportFile(DashboardRow row)
|
||||||
|
{
|
||||||
|
OpenFile(row.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = filePath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartPolling()
|
||||||
|
{
|
||||||
|
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_pollingCts = new CancellationTokenSource();
|
||||||
|
_ = PollDashboardAsync(_pollingCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPolling()
|
||||||
|
{
|
||||||
|
_pollingCts?.Cancel();
|
||||||
|
_pollingCts?.Dispose();
|
||||||
|
_pollingCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollDashboardAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
if (!anyRunning)
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = false;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
StopPolling();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RefreshLiveDataAsync()
|
||||||
|
{
|
||||||
|
foreach (var row in _dashboardRows)
|
||||||
|
{
|
||||||
|
if (!Orchestrator.IsExporting(row.SiteId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
|
||||||
|
row.LiveDetails = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatException(Exception ex)
|
||||||
|
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
|
||||||
|
|
||||||
|
private static string GetDataBasisIcon(string dataBasis)
|
||||||
|
{
|
||||||
|
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.TableView;
|
||||||
|
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.Description;
|
||||||
|
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.CloudSync;
|
||||||
|
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.Storage;
|
||||||
|
|
||||||
|
return Icons.Material.Filled.Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetDataBasisColor(string dataBasis)
|
||||||
|
{
|
||||||
|
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Success;
|
||||||
|
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Info;
|
||||||
|
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Primary;
|
||||||
|
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Secondary;
|
||||||
|
|
||||||
|
return Color.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -32,6 +32,21 @@
|
|||||||
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
|
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
|
||||||
@T("HR KPI entsperren", "Unlock HR KPI")
|
@T("HR KPI entsperren", "Unlock HR KPI")
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
<MudDivider />
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
|
<MudStack Spacing="3" Class="pt-2">
|
||||||
|
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangeHrPasswordAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!HrKpiAccess.IsConfigured)">
|
||||||
|
@T("Passwort speichern", "Save password")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
}
|
}
|
||||||
@@ -175,6 +190,10 @@ else
|
|||||||
private bool _managementView;
|
private bool _managementView;
|
||||||
private string? _hrUsername;
|
private string? _hrUsername;
|
||||||
private string? _hrPassword;
|
private string? _hrPassword;
|
||||||
|
private string? _changeUsername;
|
||||||
|
private string? _currentPassword;
|
||||||
|
private string? _newPassword;
|
||||||
|
private string? _newPasswordRepeat;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
private HrKpiResult? _result;
|
private HrKpiResult? _result;
|
||||||
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
||||||
@@ -245,6 +264,33 @@ else
|
|||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task ChangeHrPasswordAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_newPassword != _newPasswordRepeat)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HrKpiAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPassword = string.Empty;
|
||||||
|
_newPassword = string.Empty;
|
||||||
|
_newPasswordRepeat = string.Empty;
|
||||||
|
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private void LockHrKpi()
|
private void LockHrKpi()
|
||||||
{
|
{
|
||||||
HrKpiAccess.Lock();
|
HrKpiAccess.Lock();
|
||||||
|
|||||||
@@ -85,8 +85,22 @@
|
|||||||
<MudTd>@context.Land</MudTd>
|
<MudTd>@context.Land</MudTd>
|
||||||
<MudTd>@context.TSC</MudTd>
|
<MudTd>@context.TSC</MudTd>
|
||||||
<MudTd>@context.Schema</MudTd>
|
<MudTd>@context.Schema</MudTd>
|
||||||
<MudTd>@context.SourceSystem</MudTd>
|
<MudTd>
|
||||||
<MudTd>@GetConnectionTarget(context)</MudTd>
|
<MudTooltip Text="@GetConnectionKindTooltip(context)">
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@GetConnectionKindColor(context)"
|
||||||
|
Icon="@GetConnectionKindIcon(context)">
|
||||||
|
@context.SourceSystem
|
||||||
|
</MudChip>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||||
|
<MudTooltip Text="@GetConnectionKindTooltip(context)">
|
||||||
|
<MudIcon Icon="@GetConnectionKindIcon(context)" Color="@GetConnectionKindColor(context)" Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
<span>@GetConnectionTarget(context)</span>
|
||||||
|
</MudStack>
|
||||||
|
</MudTd>
|
||||||
<MudTd>
|
<MudTd>
|
||||||
@if (context.IsActive)
|
@if (context.IsActive)
|
||||||
{
|
{
|
||||||
@@ -791,6 +805,39 @@
|
|||||||
return GetServerNode(site.HanaServer);
|
return GetServerNode(site.HanaServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetConnectionKindIcon(Site site)
|
||||||
|
{
|
||||||
|
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.UploadFile;
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.CloudSync;
|
||||||
|
|
||||||
|
return Icons.Material.Filled.Storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Color GetConnectionKindColor(Site site)
|
||||||
|
{
|
||||||
|
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Warning;
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Info;
|
||||||
|
|
||||||
|
return Color.Primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetConnectionKindTooltip(Site site)
|
||||||
|
{
|
||||||
|
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "Manual Excel / CSV";
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "SAP OData Server";
|
||||||
|
|
||||||
|
return "HANA / Server";
|
||||||
|
}
|
||||||
|
|
||||||
private string GetEffectiveSapServiceUrl(Site site)
|
private string GetEffectiveSapServiceUrl(Site site)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
|
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
.Trim('/')
|
.Trim('/')
|
||||||
.ToLowerInvariant();
|
.ToLowerInvariant();
|
||||||
|
|
||||||
return path is "" or
|
return path is
|
||||||
|
"export-dashboard" or
|
||||||
"management-cockpit" or
|
"management-cockpit" or
|
||||||
"finance-cockpit/vergleich" or
|
"finance-cockpit/vergleich" or
|
||||||
"finance-cockpit/schulung" or
|
"finance-cockpit/schulung" or
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"transformations" or
|
"transformations" or
|
||||||
"finance-rules" or
|
"finance-rules" or
|
||||||
"settings" or
|
"settings" or
|
||||||
|
"admin/sessions" or
|
||||||
"logs" or
|
"logs" or
|
||||||
"source-viewer";
|
"source-viewer";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL
|
|||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
|
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
|
||||||
var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass;
|
var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass;
|
||||||
@@ -47,6 +48,7 @@ builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
|
|||||||
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
|
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
|
||||||
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
|
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
|
||||||
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
|
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
|
||||||
|
builder.Services.Configure<AdminAccessOptions>(builder.Configuration.GetSection(AdminAccessOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
|
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
|
||||||
@@ -85,6 +87,7 @@ builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaM
|
|||||||
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
|
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
|
||||||
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
|
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
|
||||||
builder.Services.AddSingleton<IUiTextService, UiTextService>();
|
builder.Services.AddSingleton<IUiTextService, UiTextService>();
|
||||||
|
builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>();
|
||||||
|
|
||||||
// Datenquellen-Adapter (Strategy per ConnectionKind).
|
// Datenquellen-Adapter (Strategy per ConnectionKind).
|
||||||
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
|
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
|
||||||
@@ -109,6 +112,7 @@ builder.Services.AddScoped<ITransformationsPageService, TransformationsPageServi
|
|||||||
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
||||||
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
||||||
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
||||||
|
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
var pathBase = app.Configuration["ASPNETCORE_PATHBASE"];
|
var pathBase = app.Configuration["ASPNETCORE_PATHBASE"];
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TrafagSalesExporter.Security;
|
||||||
|
|
||||||
|
public sealed class AdminAccessOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "AdminAccess";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string Username { get; set; } = "admin";
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
internal static class AccessPasswordSettingsWriter
|
||||||
|
{
|
||||||
|
private static readonly object FileLock = new();
|
||||||
|
|
||||||
|
public static string HashPassword(string password)
|
||||||
|
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||||
|
|
||||||
|
public static void SavePasswordHash(string contentRootPath, string sectionName, string passwordHash)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(contentRootPath, "appsettings.json");
|
||||||
|
|
||||||
|
lock (FileLock)
|
||||||
|
{
|
||||||
|
var json = File.Exists(path)
|
||||||
|
? File.ReadAllText(path, Encoding.UTF8)
|
||||||
|
: "{}";
|
||||||
|
|
||||||
|
var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject();
|
||||||
|
var section = root[sectionName] as JsonObject;
|
||||||
|
if (section is null)
|
||||||
|
{
|
||||||
|
section = new JsonObject();
|
||||||
|
root[sectionName] = section;
|
||||||
|
}
|
||||||
|
|
||||||
|
section["PasswordHash"] = passwordHash;
|
||||||
|
section["Password"] = string.Empty;
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
File.WriteAllText(path, root.ToJsonString(options), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IAccessSessionTracker
|
||||||
|
{
|
||||||
|
IReadOnlyList<AccessSessionSnapshot> GetActiveSessions();
|
||||||
|
void Register(string sessionId, string area, string username, string? remoteAddress);
|
||||||
|
void Touch(string sessionId);
|
||||||
|
void Unregister(string sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AccessSessionTracker : IAccessSessionTracker
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, AccessSessionSnapshot> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public IReadOnlyList<AccessSessionSnapshot> GetActiveSessions()
|
||||||
|
=> _sessions.Values
|
||||||
|
.OrderByDescending(session => session.LastSeenAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public void Register(string sessionId, string area, string username, string? remoteAddress)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
_sessions[sessionId] = new AccessSessionSnapshot(
|
||||||
|
sessionId,
|
||||||
|
area,
|
||||||
|
username,
|
||||||
|
string.IsNullOrWhiteSpace(remoteAddress) ? "unbekannt" : remoteAddress,
|
||||||
|
now,
|
||||||
|
now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Touch(string sessionId)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_sessions[sessionId] = session with { LastSeenAt = DateTimeOffset.Now };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unregister(string sessionId)
|
||||||
|
{
|
||||||
|
_sessions.TryRemove(sessionId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AccessSessionSnapshot(
|
||||||
|
string SessionId,
|
||||||
|
string Area,
|
||||||
|
string Username,
|
||||||
|
string RemoteAddress,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
DateTimeOffset LastSeenAt);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TrafagSalesExporter.Security;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IAdminAccessService
|
||||||
|
{
|
||||||
|
bool IsEnabled { get; }
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
bool IsUnlocked { get; }
|
||||||
|
bool TryUnlock(string username, string password);
|
||||||
|
bool TryChangePassword(string username, string currentPassword, string newPassword);
|
||||||
|
void Lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminAccessService : IAdminAccessService
|
||||||
|
{
|
||||||
|
private readonly AdminAccessOptions _options;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
|
|
||||||
|
public AdminAccessService(IOptions<AdminAccessOptions> options, IHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled => _options.Enabled;
|
||||||
|
|
||||||
|
public bool IsConfigured =>
|
||||||
|
!IsEnabled ||
|
||||||
|
!string.IsNullOrWhiteSpace(_options.Username) &&
|
||||||
|
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
||||||
|
|
||||||
|
public bool IsUnlocked { get; private set; }
|
||||||
|
|
||||||
|
public bool TryUnlock(string username, string password)
|
||||||
|
{
|
||||||
|
if (!IsEnabled)
|
||||||
|
{
|
||||||
|
IsUnlocked = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsConfigured ||
|
||||||
|
string.IsNullOrWhiteSpace(username) ||
|
||||||
|
string.IsNullOrEmpty(password) ||
|
||||||
|
!FixedEquals(username.Trim(), _options.Username.Trim()))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash)
|
||||||
|
? VerifyPasswordHash(password, _options.PasswordHash)
|
||||||
|
: FixedEquals(password, _options.Password);
|
||||||
|
|
||||||
|
IsUnlocked = valid;
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryChangePassword(string username, string currentPassword, string newPassword)
|
||||||
|
{
|
||||||
|
if (!IsEnabled ||
|
||||||
|
!IsConfigured ||
|
||||||
|
string.IsNullOrWhiteSpace(newPassword) ||
|
||||||
|
newPassword.Length < 8 ||
|
||||||
|
!TryUnlock(username, currentPassword))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
|
||||||
|
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash);
|
||||||
|
_options.PasswordHash = passwordHash;
|
||||||
|
_options.Password = string.Empty;
|
||||||
|
IsUnlocked = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Lock() => IsUnlocked = false;
|
||||||
|
|
||||||
|
private static bool VerifyPasswordHash(string password, string configuredHash)
|
||||||
|
{
|
||||||
|
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
|
||||||
|
return FixedEquals(passwordHash, configuredHash.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FixedEquals(string left, string right)
|
||||||
|
{
|
||||||
|
var leftBytes = Encoding.UTF8.GetBytes(left);
|
||||||
|
var rightBytes = Encoding.UTF8.GetBytes(right);
|
||||||
|
return leftBytes.Length == rightBytes.Length &&
|
||||||
|
CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,16 +11,28 @@ public interface IFinanceCockpitAccessService
|
|||||||
bool IsConfigured { get; }
|
bool IsConfigured { get; }
|
||||||
bool IsUnlocked { get; }
|
bool IsUnlocked { get; }
|
||||||
bool TryUnlock(string username, string password);
|
bool TryUnlock(string username, string password);
|
||||||
|
bool TryChangePassword(string username, string currentPassword, string newPassword);
|
||||||
void Lock();
|
void Lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
|
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly FinanceCockpitAccessOptions _options;
|
private readonly FinanceCockpitAccessOptions _options;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly IAccessSessionTracker _sessionTracker;
|
||||||
|
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
public FinanceCockpitAccessService(IOptions<FinanceCockpitAccessOptions> options)
|
public FinanceCockpitAccessService(
|
||||||
|
IOptions<FinanceCockpitAccessOptions> options,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IAccessSessionTracker sessionTracker)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_environment = environment;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_sessionTracker = sessionTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsEnabled => _options.Enabled;
|
public bool IsEnabled => _options.Enabled;
|
||||||
@@ -53,14 +65,48 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
|
|||||||
: FixedEquals(password, _options.Password);
|
: FixedEquals(password, _options.Password);
|
||||||
|
|
||||||
IsUnlocked = valid;
|
IsUnlocked = valid;
|
||||||
|
if (valid)
|
||||||
|
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Lock() => IsUnlocked = false;
|
public void Lock()
|
||||||
|
{
|
||||||
|
IsUnlocked = false;
|
||||||
|
_sessionTracker.Unregister(_sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryChangePassword(string username, string currentPassword, string newPassword)
|
||||||
|
{
|
||||||
|
if (!IsEnabled ||
|
||||||
|
!IsConfigured ||
|
||||||
|
string.IsNullOrWhiteSpace(newPassword) ||
|
||||||
|
newPassword.Length < 8 ||
|
||||||
|
!TryUnlock(username, currentPassword))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
|
||||||
|
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, FinanceCockpitAccessOptions.SectionName, passwordHash);
|
||||||
|
_options.PasswordHash = passwordHash;
|
||||||
|
_options.Password = string.Empty;
|
||||||
|
IsUnlocked = true;
|
||||||
|
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_sessionTracker.Unregister(_sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetRemoteAddress()
|
||||||
|
=> _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
private static bool VerifyPasswordHash(string password, string configuredHash)
|
private static bool VerifyPasswordHash(string password, string configuredHash)
|
||||||
{
|
{
|
||||||
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
|
||||||
return FixedEquals(passwordHash, configuredHash.Trim());
|
return FixedEquals(passwordHash, configuredHash.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,28 @@ public interface IHrKpiAccessService
|
|||||||
bool IsConfigured { get; }
|
bool IsConfigured { get; }
|
||||||
bool IsUnlocked { get; }
|
bool IsUnlocked { get; }
|
||||||
bool TryUnlock(string username, string password);
|
bool TryUnlock(string username, string password);
|
||||||
|
bool TryChangePassword(string username, string currentPassword, string newPassword);
|
||||||
void Lock();
|
void Lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class HrKpiAccessService : IHrKpiAccessService
|
public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly HrKpiAccessOptions _options;
|
private readonly HrKpiAccessOptions _options;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly IAccessSessionTracker _sessionTracker;
|
||||||
|
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
public HrKpiAccessService(IOptions<HrKpiAccessOptions> options)
|
public HrKpiAccessService(
|
||||||
|
IOptions<HrKpiAccessOptions> options,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IAccessSessionTracker sessionTracker)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_environment = environment;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_sessionTracker = sessionTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsEnabled => _options.Enabled;
|
public bool IsEnabled => _options.Enabled;
|
||||||
@@ -53,14 +65,48 @@ public sealed class HrKpiAccessService : IHrKpiAccessService
|
|||||||
: FixedEquals(password, _options.Password);
|
: FixedEquals(password, _options.Password);
|
||||||
|
|
||||||
IsUnlocked = valid;
|
IsUnlocked = valid;
|
||||||
|
if (valid)
|
||||||
|
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Lock() => IsUnlocked = false;
|
public void Lock()
|
||||||
|
{
|
||||||
|
IsUnlocked = false;
|
||||||
|
_sessionTracker.Unregister(_sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryChangePassword(string username, string currentPassword, string newPassword)
|
||||||
|
{
|
||||||
|
if (!IsEnabled ||
|
||||||
|
!IsConfigured ||
|
||||||
|
string.IsNullOrWhiteSpace(newPassword) ||
|
||||||
|
newPassword.Length < 8 ||
|
||||||
|
!TryUnlock(username, currentPassword))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
|
||||||
|
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, HrKpiAccessOptions.SectionName, passwordHash);
|
||||||
|
_options.PasswordHash = passwordHash;
|
||||||
|
_options.Password = string.Empty;
|
||||||
|
IsUnlocked = true;
|
||||||
|
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_sessionTracker.Unregister(_sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetRemoteAddress()
|
||||||
|
=> _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
private static bool VerifyPasswordHash(string password, string configuredHash)
|
private static bool VerifyPasswordHash(string password, string configuredHash)
|
||||||
{
|
{
|
||||||
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
|
||||||
return FixedEquals(passwordHash, configuredHash.Trim());
|
return FixedEquals(passwordHash, configuredHash.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["es"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["es"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Trafag Finance/Sales Management Cockpit"] = "Trafag Cockpit de finanzas y ventas",
|
["Trafag Finance/Sales Management Cockpit"] = "Trafag Cockpit de finanzas y ventas",
|
||||||
|
["Willkommen im Trafag Analyse Dashboard"] = "Bienvenido al panel analítico de Trafag",
|
||||||
["Finance Cockpit"] = "Cockpit financiero",
|
["Finance Cockpit"] = "Cockpit financiero",
|
||||||
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "El cockpit financiero está protegido. Inicie sesión por separado.",
|
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "El cockpit financiero está protegido. Inicie sesión por separado.",
|
||||||
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "El acceso al cockpit financiero aún no está configurado. Configure Username y PasswordHash en FinanceCockpitAccess.",
|
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "El acceso al cockpit financiero aún no está configurado. Configure Username y PasswordHash en FinanceCockpitAccess.",
|
||||||
@@ -40,6 +41,13 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["Finance Regeln"] = "Reglas financieras",
|
["Finance Regeln"] = "Reglas financieras",
|
||||||
["Settings"] = "Configuración",
|
["Settings"] = "Configuración",
|
||||||
["Logs"] = "Registros",
|
["Logs"] = "Registros",
|
||||||
|
["Aktive Logins"] = "Inicios de sesión activos",
|
||||||
|
["Admin Bereich"] = "Área de administración",
|
||||||
|
["Adminbereich ist geschützt. Bitte anmelden."] = "El área de administración está protegida. Inicie sesión.",
|
||||||
|
["Admin-Zugang ist noch nicht konfiguriert."] = "El acceso de administrador aún no está configurado.",
|
||||||
|
["Admin entsperren"] = "Desbloquear administración",
|
||||||
|
["Admin-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión como administrador.",
|
||||||
|
["Admin sperren"] = "Bloquear administración",
|
||||||
["Finance sperren"] = "Bloquear finanzas",
|
["Finance sperren"] = "Bloquear finanzas",
|
||||||
["HR KPI (Login)"] = "KPI RR. HH. (login)",
|
["HR KPI (Login)"] = "KPI RR. HH. (login)",
|
||||||
["HR Dashboard"] = "Panel HR",
|
["HR Dashboard"] = "Panel HR",
|
||||||
@@ -50,6 +58,24 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["HR-KPI-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión en HR KPI.",
|
["HR-KPI-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión en HR KPI.",
|
||||||
["Name"] = "Nombre",
|
["Name"] = "Nombre",
|
||||||
["Passwort"] = "Contraseña",
|
["Passwort"] = "Contraseña",
|
||||||
|
["Passwort ändern"] = "Cambiar contraseña",
|
||||||
|
["Aktuelles Passwort"] = "Contraseña actual",
|
||||||
|
["Neues Passwort"] = "Nueva contraseña",
|
||||||
|
["Mindestens 8 Zeichen."] = "Al menos 8 caracteres.",
|
||||||
|
["Neues Passwort wiederholen"] = "Repetir nueva contraseña",
|
||||||
|
["Passwort speichern"] = "Guardar contraseña",
|
||||||
|
["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "La nueva contraseña debe tener al menos 8 caracteres.",
|
||||||
|
["Die neuen Passwörter stimmen nicht überein."] = "Las nuevas contraseñas no coinciden.",
|
||||||
|
["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "No se pudo cambiar la contraseña. Compruebe el nombre o la contraseña actual.",
|
||||||
|
["Passwort wurde geändert."] = "La contraseña se ha cambiado.",
|
||||||
|
["HR-/Finance-Cockpit Sessions"] = "Sesiones de cockpit HR/Finance",
|
||||||
|
["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "Se cuentan los desbloqueos internos de la app desde el último inicio.",
|
||||||
|
["Bereich"] = "Área",
|
||||||
|
["IP-Adresse"] = "Dirección IP",
|
||||||
|
["Entsperrt seit"] = "Desbloqueado desde",
|
||||||
|
["Zuletzt gesehen"] = "Visto por última vez",
|
||||||
|
["Keine aktiven HR-/Finance-Logins erfasst."] = "No hay inicios de sesión HR/Finance activos registrados.",
|
||||||
|
["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "Nota: HR y Finance usan logins compartidos de la app. Esta página muestra el nombre de login usado y la sesión, no necesariamente la persona real.",
|
||||||
["Finance Cockpit entsperren"] = "Desbloquear cockpit financiero",
|
["Finance Cockpit entsperren"] = "Desbloquear cockpit financiero",
|
||||||
["Finance-Jahr"] = "Año financiero",
|
["Finance-Jahr"] = "Año financiero",
|
||||||
["Finance Summary laden"] = "Cargar resumen financiero",
|
["Finance Summary laden"] = "Cargar resumen financiero",
|
||||||
@@ -230,6 +256,7 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["it"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["it"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Trafag Finance/Sales Management Cockpit"] = "Cockpit Trafag finanza e vendite",
|
["Trafag Finance/Sales Management Cockpit"] = "Cockpit Trafag finanza e vendite",
|
||||||
|
["Willkommen im Trafag Analyse Dashboard"] = "Benvenuto nel dashboard analitico Trafag",
|
||||||
["Finance Cockpit"] = "Cockpit finance",
|
["Finance Cockpit"] = "Cockpit finance",
|
||||||
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "Il cockpit finance è protetto. Effettuare un accesso separato.",
|
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "Il cockpit finance è protetto. Effettuare un accesso separato.",
|
||||||
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "L'accesso al cockpit finance non è ancora configurato. Configurare Username e PasswordHash in FinanceCockpitAccess.",
|
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "L'accesso al cockpit finance non è ancora configurato. Configurare Username e PasswordHash in FinanceCockpitAccess.",
|
||||||
@@ -244,6 +271,13 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["Finance Regeln"] = "Regole finance",
|
["Finance Regeln"] = "Regole finance",
|
||||||
["Settings"] = "Impostazioni",
|
["Settings"] = "Impostazioni",
|
||||||
["Logs"] = "Log",
|
["Logs"] = "Log",
|
||||||
|
["Aktive Logins"] = "Login attivi",
|
||||||
|
["Admin Bereich"] = "Area admin",
|
||||||
|
["Adminbereich ist geschützt. Bitte anmelden."] = "L'area admin è protetta. Effettuare l'accesso.",
|
||||||
|
["Admin-Zugang ist noch nicht konfiguriert."] = "L'accesso admin non è ancora configurato.",
|
||||||
|
["Admin entsperren"] = "Sblocca admin",
|
||||||
|
["Admin-Anmeldung fehlgeschlagen."] = "Accesso admin non riuscito.",
|
||||||
|
["Admin sperren"] = "Blocca admin",
|
||||||
["Finance sperren"] = "Blocca finance",
|
["Finance sperren"] = "Blocca finance",
|
||||||
["HR KPI (Login)"] = "KPI HR (login)",
|
["HR KPI (Login)"] = "KPI HR (login)",
|
||||||
["HR Dashboard"] = "Dashboard HR",
|
["HR Dashboard"] = "Dashboard HR",
|
||||||
@@ -254,6 +288,24 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.",
|
["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.",
|
||||||
["Name"] = "Nome",
|
["Name"] = "Nome",
|
||||||
["Passwort"] = "Password",
|
["Passwort"] = "Password",
|
||||||
|
["Passwort ändern"] = "Cambia password",
|
||||||
|
["Aktuelles Passwort"] = "Password attuale",
|
||||||
|
["Neues Passwort"] = "Nuova password",
|
||||||
|
["Mindestens 8 Zeichen."] = "Almeno 8 caratteri.",
|
||||||
|
["Neues Passwort wiederholen"] = "Ripeti nuova password",
|
||||||
|
["Passwort speichern"] = "Salva password",
|
||||||
|
["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "La nuova password deve contenere almeno 8 caratteri.",
|
||||||
|
["Die neuen Passwörter stimmen nicht überein."] = "Le nuove password non corrispondono.",
|
||||||
|
["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "Impossibile modificare la password. Controllare nome o password attuale.",
|
||||||
|
["Passwort wurde geändert."] = "La password è stata modificata.",
|
||||||
|
["HR-/Finance-Cockpit Sessions"] = "Sessioni cockpit HR/Finance",
|
||||||
|
["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "Vengono contati gli sblocchi interni dell'app dall'ultimo avvio.",
|
||||||
|
["Bereich"] = "Area",
|
||||||
|
["IP-Adresse"] = "Indirizzo IP",
|
||||||
|
["Entsperrt seit"] = "Sbloccato da",
|
||||||
|
["Zuletzt gesehen"] = "Ultima attività",
|
||||||
|
["Keine aktiven HR-/Finance-Logins erfasst."] = "Nessun login HR/Finance attivo registrato.",
|
||||||
|
["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "Nota: HR e Finance usano login condivisi dell'app. Questa pagina mostra quindi il nome login usato e la sessione, non necessariamente la persona reale.",
|
||||||
["Finance Cockpit entsperren"] = "Sblocca cockpit finance",
|
["Finance Cockpit entsperren"] = "Sblocca cockpit finance",
|
||||||
["Finance-Jahr"] = "Anno finance",
|
["Finance-Jahr"] = "Anno finance",
|
||||||
["Finance Summary laden"] = "Carica riepilogo finance",
|
["Finance Summary laden"] = "Carica riepilogo finance",
|
||||||
@@ -434,6 +486,7 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["hi"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["hi"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट",
|
["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट",
|
||||||
|
["Willkommen im Trafag Analyse Dashboard"] = "Trafag विश्लेषण डैशबोर्ड में आपका स्वागत है",
|
||||||
["Finance Cockpit"] = "वित्त कॉकपिट",
|
["Finance Cockpit"] = "वित्त कॉकपिट",
|
||||||
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.",
|
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.",
|
||||||
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "वित्त कॉकपिट एक्सेस अभी कॉन्फ़िगर नहीं है. कृपया FinanceCockpitAccess में Username और PasswordHash सेट करें.",
|
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "वित्त कॉकपिट एक्सेस अभी कॉन्फ़िगर नहीं है. कृपया FinanceCockpitAccess में Username और PasswordHash सेट करें.",
|
||||||
@@ -448,6 +501,13 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["Finance Regeln"] = "वित्त नियम",
|
["Finance Regeln"] = "वित्त नियम",
|
||||||
["Settings"] = "सेटिंग्स",
|
["Settings"] = "सेटिंग्स",
|
||||||
["Logs"] = "लॉग",
|
["Logs"] = "लॉग",
|
||||||
|
["Aktive Logins"] = "सक्रिय लॉगिन",
|
||||||
|
["Admin Bereich"] = "Admin क्षेत्र",
|
||||||
|
["Adminbereich ist geschützt. Bitte anmelden."] = "Admin क्षेत्र सुरक्षित है. कृपया साइन इन करें.",
|
||||||
|
["Admin-Zugang ist noch nicht konfiguriert."] = "Admin एक्सेस अभी कॉन्फ़िगर नहीं है.",
|
||||||
|
["Admin entsperren"] = "Admin अनलॉक करें",
|
||||||
|
["Admin-Anmeldung fehlgeschlagen."] = "Admin साइन-इन विफल.",
|
||||||
|
["Admin sperren"] = "Admin लॉक करें",
|
||||||
["Finance sperren"] = "वित्त लॉक करें",
|
["Finance sperren"] = "वित्त लॉक करें",
|
||||||
["HR KPI (Login)"] = "HR KPI (लॉगिन)",
|
["HR KPI (Login)"] = "HR KPI (लॉगिन)",
|
||||||
["HR Dashboard"] = "HR डैशबोर्ड",
|
["HR Dashboard"] = "HR डैशबोर्ड",
|
||||||
@@ -458,6 +518,24 @@ public sealed class UiTextService : IUiTextService
|
|||||||
["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.",
|
["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.",
|
||||||
["Name"] = "नाम",
|
["Name"] = "नाम",
|
||||||
["Passwort"] = "पासवर्ड",
|
["Passwort"] = "पासवर्ड",
|
||||||
|
["Passwort ändern"] = "पासवर्ड बदलें",
|
||||||
|
["Aktuelles Passwort"] = "वर्तमान पासवर्ड",
|
||||||
|
["Neues Passwort"] = "नया पासवर्ड",
|
||||||
|
["Mindestens 8 Zeichen."] = "कम से कम 8 अक्षर.",
|
||||||
|
["Neues Passwort wiederholen"] = "नया पासवर्ड दोहराएं",
|
||||||
|
["Passwort speichern"] = "पासवर्ड सहेजें",
|
||||||
|
["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "नया पासवर्ड कम से कम 8 अक्षरों का होना चाहिए.",
|
||||||
|
["Die neuen Passwörter stimmen nicht überein."] = "नए पासवर्ड मेल नहीं खाते.",
|
||||||
|
["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "पासवर्ड बदला नहीं जा सका. नाम या वर्तमान पासवर्ड जांचें.",
|
||||||
|
["Passwort wurde geändert."] = "पासवर्ड बदल दिया गया.",
|
||||||
|
["HR-/Finance-Cockpit Sessions"] = "HR/Finance cockpit sessions",
|
||||||
|
["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "ऐप के पिछले प्रारंभ के बाद से आंतरिक अनलॉक गिने जाते हैं.",
|
||||||
|
["Bereich"] = "क्षेत्र",
|
||||||
|
["IP-Adresse"] = "IP पता",
|
||||||
|
["Entsperrt seit"] = "अनलॉक समय",
|
||||||
|
["Zuletzt gesehen"] = "अंतिम बार देखा गया",
|
||||||
|
["Keine aktiven HR-/Finance-Logins erfasst."] = "कोई सक्रिय HR/Finance लॉगिन दर्ज नहीं है.",
|
||||||
|
["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "नोट: HR और Finance साझा ऐप लॉगिन का उपयोग करते हैं. इसलिए यह पेज उपयोग किया गया लॉगिन नाम और सत्र दिखाता है, जरूरी नहीं कि वास्तविक व्यक्ति.",
|
||||||
["Finance Cockpit entsperren"] = "वित्त कॉकपिट अनलॉक करें",
|
["Finance Cockpit entsperren"] = "वित्त कॉकपिट अनलॉक करें",
|
||||||
["Finance-Jahr"] = "वित्त वर्ष",
|
["Finance-Jahr"] = "वित्त वर्ष",
|
||||||
["Finance Summary laden"] = "वित्त सारांश लोड करें",
|
["Finance Summary laden"] = "वित्त सारांश लोड करें",
|
||||||
|
|||||||
@@ -38,5 +38,10 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"Username": "finance",
|
"Username": "finance",
|
||||||
"PasswordHash": "1446F41A1BF8ABCF5DED217400CDC5D671F9E1B58753162A228F23FB7C844575"
|
"PasswordHash": "1446F41A1BF8ABCF5DED217400CDC5D671F9E1B58753162A228F23FB7C844575"
|
||||||
|
},
|
||||||
|
"AdminAccess": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Username": "admin",
|
||||||
|
"PasswordHash": "F0101E12FBCCDD6D2645B214B8732F5AEDFFB2DABBE7EE98043E68DB3BD9ADA4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,42 @@ Serverbefund:
|
|||||||
- Dadurch erreichen Requests weder `diag.txt` noch `BiDashboard.dll`.
|
- Dadurch erreichen Requests weder `diag.txt` noch `BiDashboard.dll`.
|
||||||
- Marco/IT muss in IIS die SSL Settings pruefen und Client Certificates auf `Ignore` oder hoechstens `Accept` setzen, nicht `Require`.
|
- Marco/IT muss in IIS die SSL Settings pruefen und Client Certificates auf `Ignore` oder hoechstens `Accept` setzen, nicht `Require`.
|
||||||
|
|
||||||
|
## Adminbereich und Passwortwechsel 2026-05-21
|
||||||
|
|
||||||
|
Geaendert:
|
||||||
|
|
||||||
|
- Finance Cockpit und HR KPI Login-Masken haben einen Bereich `Passwort ändern`.
|
||||||
|
- Passwortaenderung verlangt Benutzername, aktuelles Passwort, neues Passwort und Wiederholung.
|
||||||
|
- Neue Passwoerter muessen mindestens 8 Zeichen haben.
|
||||||
|
- Gespeichert wird ein SHA-256-Hash in `appsettings.json`, kein Klartext.
|
||||||
|
- Neuer interner Adminbereich `/admin/sessions`.
|
||||||
|
- Der Adminbereich hat eine eigene App-interne Sperre `AdminAccess`.
|
||||||
|
- Adminseite `Aktive Logins` zeigt App-interne HR-/Finance-Entsperrungen seit dem letzten App-Start:
|
||||||
|
- Bereich
|
||||||
|
- Login-Name
|
||||||
|
- IP-Adresse, soweit aus dem Request verfuegbar
|
||||||
|
- Entsperrt seit
|
||||||
|
- Zuletzt gesehen
|
||||||
|
- Hinweis: Da HR und Finance gemeinsame App-Logins verwenden, zeigt die Seite nicht zwingend die echte Person, sondern die verwendete App-Session.
|
||||||
|
- Standorte-Tabelle zeigt jetzt Icons fuer den Quellentyp:
|
||||||
|
- Upload-Datei = Manual Excel / CSV
|
||||||
|
- Cloud Sync = SAP OData
|
||||||
|
- Storage = HANA / Server
|
||||||
|
|
||||||
|
Initialer Adminzugang:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Username: admin
|
||||||
|
Initialpasswort: TrafagAdmin2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach erster Nutzung sollte das Adminpasswort ueber die Admin-Loginmaske geaendert werden.
|
||||||
|
|
||||||
|
Verifiziert:
|
||||||
|
|
||||||
|
- `dotnet build .\TrafagSalesExporter.csproj --no-restore --verbosity minimal -p:OutDir=C:\TMP\trafag_out\`
|
||||||
|
- Ergebnis: Build erfolgreich, nur bestehende MudBlazor-Analyzer-Warnungen zu `Dense` auf vorhandenen Controls.
|
||||||
|
|
||||||
## Markdown-Doku und Anwenderdokus nachgezogen 2026-05-20
|
## Markdown-Doku und Anwenderdokus nachgezogen 2026-05-20
|
||||||
|
|
||||||
Geaendert:
|
Geaendert:
|
||||||
|
|||||||
Reference in New Issue
Block a user