manometer

This commit is contained in:
2026-04-17 12:00:03 +02:00
parent bec0410ef4
commit eb187cdc15
15 changed files with 1817 additions and 43 deletions
@@ -134,6 +134,46 @@
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Cockpit Manometer", "Cockpit gauges")</MudText>
<MudText Typo="Typo.caption" Class="d-block mb-3">
@T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.")
</MudText>
<MudGrid>
@foreach (var gauge in BuildCentralGauges(_centralResult))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 cockpit-gauge-card" Elevation="0">
<MudText Typo="Typo.caption" Class="d-block mb-1">@gauge.Title</MudText>
<div class="cockpit-gauge-wrap">
<svg viewBox="0 0 220 140" class="cockpit-gauge" role="img" aria-label="@gauge.Title">
<path d="@GaugeArcPath"
fill="none"
stroke="#d7e2ea"
stroke-width="16"
stroke-linecap="round" />
<path d="@GaugeArcPath"
fill="none"
stroke="@gauge.Color"
stroke-width="16"
stroke-linecap="round"
pathLength="100"
stroke-dasharray="@BuildGaugeDashArray(gauge.Percent)" />
<line x1="110" y1="110" x2="@BuildGaugeNeedleX(gauge.Percent)" y2="@BuildGaugeNeedleY(gauge.Percent)"
stroke="#23313d"
stroke-width="5"
stroke-linecap="round" />
<circle cx="110" cy="110" r="8" fill="#23313d" />
<text x="110" y="76" text-anchor="middle" class="cockpit-gauge-value">@gauge.DisplayValue</text>
<text x="110" y="96" text-anchor="middle" class="cockpit-gauge-subtitle">@gauge.Subtitle</text>
</svg>
</div>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _centralResult.Notices)
@@ -248,9 +288,42 @@
</MudPaper>
}
<style>
.cockpit-gauge-card {
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fa 100%);
border: 1px solid #dce7ee;
border-radius: 18px;
min-height: 220px;
}
.cockpit-gauge-wrap {
display: flex;
justify-content: center;
align-items: center;
}
.cockpit-gauge {
width: 100%;
max-width: 240px;
height: auto;
}
.cockpit-gauge-value {
font-size: 22px;
font-weight: 700;
fill: #153047;
}
.cockpit-gauge-subtitle {
font-size: 11px;
fill: #607587;
}
</style>
@code {
private List<ManagementCockpitFileOption> _files = [];
private List<int> _centralYears = [];
private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110";
private string? _selectedFilePath;
private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult;
@@ -341,6 +414,181 @@
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
}
private List<CentralGaugeModel> BuildCentralGauges(ManagementCockpitCentralResult result)
{
var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount;
var sourceDominance = result.SourceSystemTotals.Count == 0
? 0m
: result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var countryDominance = result.CountryTotals.Count == 0
? 0m
: result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var periodCoverage = BuildPeriodCoveragePercent(result);
var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals);
var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals);
var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m);
var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result);
return
[
new CentralGaugeModel
{
Title = T("Rechnungsdichte", "Invoice density"),
Percent = invoiceDensity,
DisplayValue = $"{invoiceDensity:F0}%",
Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"),
Color = "#1f8a70"
},
new CentralGaugeModel
{
Title = T("Quellen-Dominanz", "Source dominance"),
Percent = sourceDominance,
DisplayValue = $"{sourceDominance:F0}%",
Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"),
Color = "#d9822b"
},
new CentralGaugeModel
{
Title = T("Land-Dominanz", "Country dominance"),
Percent = countryDominance,
DisplayValue = $"{countryDominance:F0}%",
Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"),
Color = "#c4496b"
},
new CentralGaugeModel
{
Title = T("Perioden-Abdeckung", "Period coverage"),
Percent = periodCoverage,
DisplayValue = $"{periodCoverage:F0}%",
Subtitle = BuildPeriodGaugeSubtitle(result),
Color = "#3d7ff0"
},
new CentralGaugeModel
{
Title = T("Top-Land Umsatz", "Top country sales"),
Percent = topCountrySalesShare,
DisplayValue = $"{topCountrySalesShare:F0}%",
Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"),
Color = "#7f56d9"
},
new CentralGaugeModel
{
Title = T("Top-Quelle Umsatz", "Top source sales"),
Percent = topSourceSalesShare,
DisplayValue = $"{topSourceSalesShare:F0}%",
Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"),
Color = "#0f9fb5"
},
new CentralGaugeModel
{
Title = T("Waehrungs-Komplexitaet", "Currency complexity"),
Percent = currencyComplexity,
DisplayValue = result.Summary.CurrencyCount.ToString("N0"),
Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"),
Color = "#b54708"
},
new CentralGaugeModel
{
Title = T("Monat gegen Peak", "Month vs peak"),
Percent = peakVsAverageMonth,
DisplayValue = $"{peakVsAverageMonth:F0}%",
Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"),
Color = "#d92d20"
}
];
}
private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result)
{
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
return 0m;
if (result.Filter.Month.HasValue)
{
var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value);
var coveredDays = result.DailyTotals
.Select(x => x.Day)
.Where(x => x.HasValue)
.Distinct()
.Count();
return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth;
}
var coveredMonths = result.MonthlyTotals
.Select(x => x.Month)
.Where(x => x.HasValue)
.Distinct()
.Count();
return coveredMonths * 100m / 12m;
}
private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result)
=> result.Filter.Month.HasValue
? T("Tage mit Daten im Monat", "Days with data in month")
: T("Monate mit Daten im Jahr", "Months with data in year");
private static decimal BuildTopSalesSharePercent(IEnumerable<ManagementCockpitDimensionValueRow> rows)
{
var materialized = rows.ToList();
if (materialized.Count == 0)
return 0m;
var total = materialized.Sum(x => x.SalesValue);
if (total == 0)
return 0m;
return materialized.Max(x => x.SalesValue) * 100m / total;
}
private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result)
{
var monthRows = result.MonthlyTotals.ToList();
if (monthRows.Count == 0)
return 0m;
var groupedMonths = monthRows
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Sum(x => x.SalesValue))
.ToList();
if (groupedMonths.Count == 0)
return 0m;
var peak = groupedMonths.Max();
if (peak == 0)
return 0m;
var average = groupedMonths.Average();
return Math.Min(100m, average * 100m / peak);
}
private static string BuildGaugeDashArray(decimal percent)
=> $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100";
private static string BuildGaugeNeedleX(decimal percent)
=> GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static string BuildGaugeNeedleY(decimal percent)
=> GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d)
{
var clamped = Math.Clamp((double)percent, 0d, 100d);
var angle = Math.PI * (1d - clamped / 100d);
var x = 110d + radius * Math.Cos(angle);
var y = 110d - radius * Math.Sin(angle);
return (x, y);
}
private sealed class CentralGaugeModel
{
public string Title { get; set; } = string.Empty;
public decimal Percent { get; set; }
public string DisplayValue { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public string Color { get; set; } = "#3d7ff0";
}
}
@code {
@@ -9,6 +9,7 @@
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject ISharePointUploadService SharePointService
@inject IAppEventLogService AppEventLogService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -141,9 +142,44 @@
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
@if (UsesHanaConnection())
{
<MudStack Row Spacing="2" Class="mb-2">
<MudButton Variant="Variant.Outlined" Color="Color.Info"
StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadAvailableSchemasAsync"
Disabled="_loadingSchemas">
@if (_loadingSchemas)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade Schemas...")
}
else
{
@("Schemas laden")
}
</MudButton>
@if (_availableSchemas.Count > 0)
{
<MudSelect T="string" Value="_editingSite.Schema"
ValueChanged="OnSchemaSelected"
Label="Gefundene Schemas"
Dense
Style="min-width: 260px;">
@foreach (var schema in _availableSchemas)
{
<MudSelectItem Value="@schema">@schema</MudSelectItem>
}
</MudSelect>
}
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
Die Liste wird aus der zentralen HANA-Verbindung des Quellsystems gelesen und auf typische B1-Schemas eingeschraenkt.
</MudText>
}
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
<MudSelect T="string" Value="_editingSite.SourceSystem" ValueChanged="OnSourceSystemChanged" Label="Quellsystem" Required>
@foreach (var system in GetAvailableSourceSystems())
{
<MudSelectItem Value="@system.Code">@GetSourceSystemLabel(system)</MudSelectItem>
@@ -351,6 +387,13 @@
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen.
</MudAlert>
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx."
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
Disabled="_uploadingManualImport" Class="mb-3">
Pfad pruefen
</MudButton>
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
@if (_uploadingManualImport)
{
@@ -394,6 +437,7 @@
private List<Site> _sites = new();
private List<SourceSystemDefinition> _sourceSystemDefinitions = new();
private List<string> _sapEntitySetsCache = [];
private List<string> _availableSchemas = [];
private List<string> _sapAvailableSourceExpressions = [];
private Dictionary<string, List<string>> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
private List<SapSourceDefinition> _sapSources = [];
@@ -411,6 +455,7 @@
private bool _refreshingSapSourceFields;
private bool _savingServer;
private bool _savingSite;
private bool _loadingSchemas;
private bool _uploadingManualImport;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
@@ -625,6 +670,7 @@
HanaServerId = null,
ManualImportFilePath = string.Empty
};
_availableSchemas = [];
_sapEntitySetsCache = [];
_sapAvailableSourceExpressions = [];
_sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
@@ -657,6 +703,7 @@
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
_availableSchemas = [];
_sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache);
using var db = DbFactory.CreateDbContext();
_sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList();
@@ -798,6 +845,19 @@
return centralServer.Id;
}
private Task OnSchemaSelected(string schema)
{
_editingSite.Schema = schema;
return Task.CompletedTask;
}
private Task OnSourceSystemChanged(string value)
{
_editingSite.SourceSystem = value;
_availableSchemas = [];
return Task.CompletedTask;
}
private IEnumerable<SourceSystemDefinition> GetAvailableSourceSystems()
=> _sourceSystemDefinitions
.Where(x => x.IsActive || string.Equals(x.Code, _editingSite.SourceSystem, StringComparison.OrdinalIgnoreCase))
@@ -871,6 +931,85 @@
return $"{centralServer.Name} | {GetServerNode(centralServer)}";
}
private async Task LoadAvailableSchemasAsync()
{
if (_loadingSchemas)
return;
_loadingSchemas = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem);
if (sourceDefinition is null)
throw new InvalidOperationException($"Quellsystem '{_editingSite.SourceSystem}' nicht gefunden.");
var centralServer = await db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == _editingSite.SourceSystem);
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} ist keine gueltige zentrale HANA-Konfiguration vorhanden.");
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride)
? sourceDefinition.CentralUsername ?? string.Empty
: _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride)
? sourceDefinition.CentralPassword ?? string.Empty
: _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var lookupServer = new HanaServer
{
Id = centralServer.Id,
SourceSystem = centralServer.SourceSystem,
Name = centralServer.Name,
Host = centralServer.Host,
Port = centralServer.Port,
Username = username.Trim(),
Password = password,
DatabaseName = centralServer.DatabaseName,
UseSsl = centralServer.UseSsl,
ValidateCertificate = centralServer.ValidateCertificate,
AdditionalParams = centralServer.AdditionalParams
};
var schemas = await Task.Run(() => HanaService.GetAvailableSchemas(lookupServer));
_availableSchemas = schemas
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
if (_availableSchemas.Count == 0)
{
Snackbar.Add("Keine passenden Schemas gefunden.", Severity.Info);
return;
}
if (string.IsNullOrWhiteSpace(_editingSite.Schema) ||
!_availableSchemas.Contains(_editingSite.Schema, StringComparer.OrdinalIgnoreCase))
{
_editingSite.Schema = _availableSchemas[0];
}
Snackbar.Add($"{_availableSchemas.Count} Schemas geladen.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Schemas laden fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_loadingSchemas = false;
}
}
private async Task RefreshSapEntitySets()
{
if (_refreshingSapEntitySets)
@@ -993,6 +1132,62 @@
}
}
private async Task ValidateManualImportPathAsync()
{
try
{
_editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim();
if (string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath))
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
if (!string.Equals(Path.GetExtension(_editingSite.ManualImportFilePath), ".xlsx", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben.");
if (File.Exists(_editingSite.ManualImportFilePath))
{
_editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(_editingSite.ManualImportFilePath);
}
else if (LooksLikeSharePointReference(_editingSite.ManualImportFilePath))
{
using var db = await DbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
{
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
var tempPath = await SharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, _editingSite.ManualImportFilePath);
try
{
_editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(tempPath);
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
else
{
throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {_editingSite.ManualImportFilePath}");
}
Snackbar.Add("Dateipfad ist gueltig und die Excel-Datei ist erreichbar.", Severity.Success);
await AppEventLogService.WriteAsync("ManualImport", "Dateipfad erfolgreich geprueft", siteId: _editingSite.Id, land: _editingSite.Land, details: _editingSite.ManualImportFilePath);
}
catch (Exception ex)
{
Snackbar.Add($"Pfadpruefung fehlgeschlagen: {ex.Message}", Severity.Error);
await AppEventLogService.WriteAsync("ManualImport", "Dateipfadpruefung fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString());
}
}
private static List<string> ParseSapEntitySets(string json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -1011,6 +1206,12 @@
private static string SerializeSapEntitySets(List<string> entitySets)
=> JsonSerializer.Serialize(entitySets);
private static bool LooksLikeSharePointReference(string path)
=> path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
private void AddSapSource()
{
_sapSources.Add(new SapSourceDefinition