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)">
|
||||
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
|
||||
</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>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private string? _username;
|
||||
private string? _password;
|
||||
private string? _changeUsername;
|
||||
private string? _currentPassword;
|
||||
private string? _newPassword;
|
||||
private string? _newPasswordRepeat;
|
||||
|
||||
private Task UnlockAsync()
|
||||
{
|
||||
@@ -43,5 +62,32 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<MudNavMenu>
|
||||
<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")
|
||||
</MudNavLink>
|
||||
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
||||
@@ -62,6 +62,9 @@
|
||||
@T("HR KPI Schulung", "HR KPI training")
|
||||
</MudNavLink>
|
||||
</MudNavGroup>
|
||||
<MudNavLink Href="/admin/sessions" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PeopleAlt">
|
||||
@T("Admin Bereich", "Admin area")
|
||||
</MudNavLink>
|
||||
</MudNavMenu>
|
||||
|
||||
@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 "/"
|
||||
@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>
|
||||
<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">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
|
||||
<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>
|
||||
</MudPaper>
|
||||
<line x1="126" y1="260" x2="95" y2="260" class="gauge-tick" />
|
||||
<line x1="177" y1="137" x2="155" y2="115" class="gauge-tick" />
|
||||
<line x1="300" y1="86" x2="300" y2="55" class="gauge-tick" />
|
||||
<line x1="423" y1="137" x2="445" y2="115" class="gauge-tick" />
|
||||
<line x1="474" y1="260" x2="505" y2="260" class="gauge-tick" />
|
||||
|
||||
@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>
|
||||
}
|
||||
<text x="150" y="230" class="gauge-label">0</text>
|
||||
<text x="205" y="154" class="gauge-label">25</text>
|
||||
<text x="300" y="126" class="gauge-label">50</text>
|
||||
<text x="395" y="154" class="gauge-label">75</text>
|
||||
<text x="450" y="230" class="gauge-label">100</text>
|
||||
<text x="300" y="222" class="gauge-brand">TRAFAG</text>
|
||||
|
||||
@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>
|
||||
}
|
||||
<g class="gauge-needle">
|
||||
<line x1="300" y1="260" x2="300" y2="96" class="needle-line" />
|
||||
</g>
|
||||
<circle cx="300" cy="260" r="28" fill="#050505" />
|
||||
</svg>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@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();
|
||||
<style>
|
||||
.home-shell {
|
||||
min-height: calc(100vh - 112px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
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;
|
||||
.home-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
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);
|
||||
.home-manometer {
|
||||
width: min(336px, 58vw);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
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);
|
||||
.home-welcome {
|
||||
color: #050505;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
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);
|
||||
.gauge-outer,
|
||||
.gauge-inner,
|
||||
.gauge-tick,
|
||||
.needle-line {
|
||||
fill: none;
|
||||
stroke: #050505;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
.gauge-outer {
|
||||
stroke-width: 16;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopPolling();
|
||||
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
|
||||
.gauge-inner {
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
private void OpenExportFile(DashboardRow row)
|
||||
{
|
||||
OpenFile(row.FilePath);
|
||||
.gauge-tick {
|
||||
stroke-width: 7;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
.gauge-label {
|
||||
fill: #050505;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
private void StartPolling()
|
||||
{
|
||||
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
_pollingCts = new CancellationTokenSource();
|
||||
_ = PollDashboardAsync(_pollingCts.Token);
|
||||
.gauge-brand {
|
||||
fill: #050505;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 4px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
private void StopPolling()
|
||||
{
|
||||
_pollingCts?.Cancel();
|
||||
_pollingCts?.Dispose();
|
||||
_pollingCts = null;
|
||||
.needle-line {
|
||||
stroke-width: 9;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
.gauge-needle {
|
||||
transform-origin: 300px 260px;
|
||||
animation: home-gauge-sweep 6.2s infinite cubic-bezier(.42, 0, .2, 1);
|
||||
}
|
||||
|
||||
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;
|
||||
@@keyframes home-gauge-sweep {
|
||||
0% { transform: rotate(-58deg); }
|
||||
9% { transform: rotate(-12deg); }
|
||||
18% { transform: rotate(43deg); }
|
||||
31% { transform: rotate(8deg); }
|
||||
44% { transform: rotate(68deg); }
|
||||
58% { transform: rotate(-35deg); }
|
||||
72% { transform: rotate(24deg); }
|
||||
86% { transform: rotate(56deg); }
|
||||
100% { transform: rotate(-58deg); }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
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)">
|
||||
@T("HR KPI entsperren", "Unlock HR KPI")
|
||||
</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>
|
||||
</MudPaper>
|
||||
}
|
||||
@@ -175,6 +190,10 @@ else
|
||||
private bool _managementView;
|
||||
private string? _hrUsername;
|
||||
private string? _hrPassword;
|
||||
private string? _changeUsername;
|
||||
private string? _currentPassword;
|
||||
private string? _newPassword;
|
||||
private string? _newPasswordRepeat;
|
||||
private bool _loading;
|
||||
private HrKpiResult? _result;
|
||||
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
||||
@@ -245,6 +264,33 @@ else
|
||||
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()
|
||||
{
|
||||
HrKpiAccess.Lock();
|
||||
|
||||
@@ -85,8 +85,22 @@
|
||||
<MudTd>@context.Land</MudTd>
|
||||
<MudTd>@context.TSC</MudTd>
|
||||
<MudTd>@context.Schema</MudTd>
|
||||
<MudTd>@context.SourceSystem</MudTd>
|
||||
<MudTd>@GetConnectionTarget(context)</MudTd>
|
||||
<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>
|
||||
@if (context.IsActive)
|
||||
{
|
||||
@@ -791,6 +805,39 @@
|
||||
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)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
.Trim('/')
|
||||
.ToLowerInvariant();
|
||||
|
||||
return path is "" or
|
||||
return path is
|
||||
"export-dashboard" or
|
||||
"management-cockpit" or
|
||||
"finance-cockpit/vergleich" or
|
||||
"finance-cockpit/schulung" or
|
||||
@@ -49,6 +50,7 @@
|
||||
"transformations" or
|
||||
"finance-rules" or
|
||||
"settings" or
|
||||
"admin/sessions" or
|
||||
"logs" or
|
||||
"source-viewer";
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
|
||||
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<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
|
||||
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
|
||||
builder.Services.Configure<AdminAccessOptions>(builder.Configuration.GetSection(AdminAccessOptions.SectionName));
|
||||
|
||||
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||
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<IDatabaseInitializationService, DatabaseInitializationService>();
|
||||
builder.Services.AddSingleton<IUiTextService, UiTextService>();
|
||||
builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>();
|
||||
|
||||
// Datenquellen-Adapter (Strategy per ConnectionKind).
|
||||
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
|
||||
@@ -109,6 +112,7 @@ builder.Services.AddScoped<ITransformationsPageService, TransformationsPageServi
|
||||
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
||||
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
||||
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
||||
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
|
||||
|
||||
var app = builder.Build();
|
||||
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 IsUnlocked { get; }
|
||||
bool TryUnlock(string username, string password);
|
||||
bool TryChangePassword(string username, string currentPassword, string newPassword);
|
||||
void Lock();
|
||||
}
|
||||
|
||||
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
|
||||
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService, IDisposable
|
||||
{
|
||||
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;
|
||||
_environment = environment;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_sessionTracker = sessionTracker;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _options.Enabled;
|
||||
@@ -53,14 +65,48 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
|
||||
: FixedEquals(password, _options.Password);
|
||||
|
||||
IsUnlocked = valid;
|
||||
if (valid)
|
||||
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
||||
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)
|
||||
{
|
||||
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
|
||||
return FixedEquals(passwordHash, configuredHash.Trim());
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,28 @@ public interface IHrKpiAccessService
|
||||
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 HrKpiAccessService : IHrKpiAccessService
|
||||
public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
|
||||
{
|
||||
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;
|
||||
_environment = environment;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_sessionTracker = sessionTracker;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _options.Enabled;
|
||||
@@ -53,14 +65,48 @@ public sealed class HrKpiAccessService : IHrKpiAccessService
|
||||
: FixedEquals(password, _options.Password);
|
||||
|
||||
IsUnlocked = valid;
|
||||
if (valid)
|
||||
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
||||
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)
|
||||
{
|
||||
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
|
||||
return FixedEquals(passwordHash, configuredHash.Trim());
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class UiTextService : IUiTextService
|
||||
["es"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["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 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.",
|
||||
@@ -40,6 +41,13 @@ public sealed class UiTextService : IUiTextService
|
||||
["Finance Regeln"] = "Reglas financieras",
|
||||
["Settings"] = "Configuración",
|
||||
["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",
|
||||
["HR KPI (Login)"] = "KPI RR. HH. (login)",
|
||||
["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.",
|
||||
["Name"] = "Nombre",
|
||||
["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-Jahr"] = "Año financiero",
|
||||
["Finance Summary laden"] = "Cargar resumen financiero",
|
||||
@@ -230,6 +256,7 @@ public sealed class UiTextService : IUiTextService
|
||||
["it"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["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 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.",
|
||||
@@ -244,6 +271,13 @@ public sealed class UiTextService : IUiTextService
|
||||
["Finance Regeln"] = "Regole finance",
|
||||
["Settings"] = "Impostazioni",
|
||||
["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",
|
||||
["HR KPI (Login)"] = "KPI HR (login)",
|
||||
["HR Dashboard"] = "Dashboard HR",
|
||||
@@ -254,6 +288,24 @@ public sealed class UiTextService : IUiTextService
|
||||
["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.",
|
||||
["Name"] = "Nome",
|
||||
["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-Jahr"] = "Anno finance",
|
||||
["Finance Summary laden"] = "Carica riepilogo finance",
|
||||
@@ -434,6 +486,7 @@ public sealed class UiTextService : IUiTextService
|
||||
["hi"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट",
|
||||
["Willkommen im Trafag Analyse Dashboard"] = "Trafag विश्लेषण डैशबोर्ड में आपका स्वागत है",
|
||||
["Finance Cockpit"] = "वित्त कॉकपिट",
|
||||
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.",
|
||||
["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"] = "वित्त नियम",
|
||||
["Settings"] = "सेटिंग्स",
|
||||
["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"] = "वित्त लॉक करें",
|
||||
["HR KPI (Login)"] = "HR KPI (लॉगिन)",
|
||||
["HR Dashboard"] = "HR डैशबोर्ड",
|
||||
@@ -458,6 +518,24 @@ public sealed class UiTextService : IUiTextService
|
||||
["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.",
|
||||
["Name"] = "नाम",
|
||||
["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-Jahr"] = "वित्त वर्ष",
|
||||
["Finance Summary laden"] = "वित्त सारांश लोड करें",
|
||||
|
||||
@@ -38,5 +38,10 @@
|
||||
"Enabled": true,
|
||||
"Username": "finance",
|
||||
"PasswordHash": "1446F41A1BF8ABCF5DED217400CDC5D671F9E1B58753162A228F23FB7C844575"
|
||||
},
|
||||
"AdminAccess": {
|
||||
"Enabled": true,
|
||||
"Username": "admin",
|
||||
"PasswordHash": "F0101E12FBCCDD6D2645B214B8732F5AEDFFB2DABBE7EE98043E68DB3BD9ADA4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,42 @@ Serverbefund:
|
||||
- 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`.
|
||||
|
||||
## 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
|
||||
|
||||
Geaendert:
|
||||
|
||||
Reference in New Issue
Block a user