Improve keyuser export workflow

This commit is contained in:
2026-05-20 09:52:55 +02:00
parent b5e0545fbf
commit a1fdea56ba
10 changed files with 409 additions and 4 deletions
@@ -14,6 +14,9 @@
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
@T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink>
<MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
@T("Manuelle Importe", "Manual imports")
</MudNavLink>
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
@@ -37,6 +37,25 @@
</MudStack>
</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>
@@ -155,6 +174,8 @@
@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;
@@ -171,6 +192,8 @@
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;
@@ -178,6 +201,12 @@
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();
@@ -260,6 +289,9 @@
{
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))
{
@@ -12,7 +12,7 @@
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudText Typo="Typo.caption">@T("Gleiche Berechnungslogik wie FinanceProbe/Testprogramm", "Same calculation logic as FinanceProbe/test program")</MudText>
<MudText Typo="Typo.caption">@T("Verbindliche Finance-Sicht aus CentralSalesRecords", "Authoritative finance view from CentralSalesRecords")</MudText>
</div>
<MudSpacer />
<MudButton Variant="@(_hideRowsWithoutActual ? Variant.Filled : Variant.Outlined)"
@@ -189,7 +189,9 @@
if (row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase))
return T("Sage ImporteNeto; REC/Credit Notes negativ; Zuschlaege/Nebenkosten noch pruefen.", "Sage ImporteNeto; REC/credit notes negative; surcharges/charges still to check.");
if (row.Key.Equals("IT", StringComparison.OrdinalIgnoreCase))
return T("B1 Arbeitsfilter 47005 ohne 4700504 plus provisorischer Kundenausschluss.", "B1 working filter 47005 excluding 4700504 plus provisional customer exclusions.");
return T("Bestaetigte IT-Regel: Trafag Italia ausgeschlossen; doppelte Zeilen ohne Supplier country nur einmal.", "Confirmed IT rule: Trafag Italia excluded; duplicate rows without supplier country counted once.");
if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase))
return T("Alphaplan Excel; Kundenlaender/Filter fuer offiziellen DE-Istwert noch fachlich offen.", "Alphaplan Excel; customer countries/filters for official DE actual are still open.");
if (row.Key.Equals("FR", StringComparison.OrdinalIgnoreCase) ||
row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) ||
row.Key.Equals("US", StringComparison.OrdinalIgnoreCase))
@@ -55,6 +55,10 @@
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.")
</MudAlert>
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.",
"This analysis is a plausibility/raw-data view. For the authoritative finance reconciliation, use `Actual/reference comparison` or the `Finance | ...` columns in the final Excel.")
</MudAlert>
<MudGrid>
<MudItem xs="12" md="2">
<MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense>
@@ -0,0 +1,192 @@
@page "/manual-imports"
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IStandortePageService StandortePageService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Manuelle Importe", "Manual imports")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Manuelle Importe", "Manual imports")</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
@T("Diese Seite ist fuer Keyuser: Hier werden Excel-/CSV-Dateien fuer manuelle Laender wie DE, UK und ES hinterlegt und aktiviert. Technische Spaltenmappings bleiben in Admin -> Standorte.",
"This page is for key users: Excel/CSV files for manual countries such as DE, UK and ES are maintained and activated here. Technical column mappings remain in Admin -> Sites.")
</MudAlert>
<MudTable Items="_rows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh>@T("Datei / SharePoint-Ordner", "File / SharePoint folder")</MudTh>
<MudTh>@T("Letzter Upload", "Last upload")</MudTh>
<MudTh>@T("Aktionen", "Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd><MudSwitch @bind-Value="context.IsActive" Color="Color.Primary" /></MudTd>
<MudTd>
<MudTextField @bind-Value="context.ManualImportFilePath"
Placeholder="@T("lokaler Pfad, UNC, SharePoint-Datei oder SharePoint-Ordner", "local path, UNC, SharePoint file or SharePoint folder")"
Margin="Margin.Dense" />
</MudTd>
<MudTd>@(context.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
OnClick="() => ValidatePathAsync(context)" Disabled="_busySiteId == context.Id">
@T("Pfad pruefen", "Check path")
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
OnClick="() => SaveAsync(context)" Disabled="_busySiteId == context.Id">
@T("Speichern", "Save")
</MudButton>
</MudStack>
<InputFile OnChange="args => UploadAsync(context, args)" accept=".xlsx,.csv" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine manuellen Excel-/CSV-Standorte gefunden.", "No manual Excel/CSV sites found.")</MudText>
</NoRecordsContent>
</MudTable>
@code {
private List<ManualImportRow> _rows = [];
private bool _loading = true;
private int? _busySiteId;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
await using var db = await DbFactory.CreateDbContextAsync();
var manualSourceCodes = await db.SourceSystemDefinitions
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.ManualExcel)
.Select(x => x.Code)
.ToListAsync();
_rows = await db.Sites
.Where(site => manualSourceCodes.Contains(site.SourceSystem))
.OrderBy(site => site.Land)
.ThenBy(site => site.TSC)
.Select(site => new ManualImportRow
{
Id = site.Id,
Land = site.Land,
TSC = site.TSC,
IsActive = site.IsActive,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc
})
.ToListAsync();
_loading = false;
}
private async Task SaveAsync(ManualImportRow row)
{
_busySiteId = row.Id;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var site = await db.Sites.FirstAsync(x => x.Id == row.Id);
site.IsActive = row.IsActive;
site.ManualImportFilePath = row.ManualImportFilePath.Trim();
site.ManualImportLastUploadedAtUtc = row.ManualImportLastUploadedAtUtc;
await db.SaveChangesAsync();
Snackbar.Add(T("Import-Einstellungen gespeichert.", "Import settings saved."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Speichern fehlgeschlagen", "Save failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private async Task ValidatePathAsync(ManualImportRow row)
{
_busySiteId = row.Id;
try
{
row.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(row.ManualImportFilePath);
Snackbar.Add(T("Datei oder SharePoint-Referenz ist erreichbar.", "File or SharePoint reference is reachable."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Pfadpruefung fehlgeschlagen", "Path check failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private async Task UploadAsync(ManualImportRow row, InputFileChangeEventArgs args)
{
var file = args.File;
if (file is null)
return;
_busySiteId = row.Id;
try
{
var extension = Path.GetExtension(file.Name);
if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(T("Bitte eine .xlsx- oder .csv-Datei auswaehlen.", "Please choose a .xlsx or .csv file."));
}
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
Directory.CreateDirectory(uploadDirectory);
var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name)
.Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_'));
if (string.IsNullOrWhiteSpace(safeBaseName))
safeBaseName = "manual_import";
var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}");
await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024))
await using (var targetStream = File.Create(targetPath))
{
await sourceStream.CopyToAsync(targetStream);
}
row.ManualImportFilePath = targetPath;
row.ManualImportLastUploadedAtUtc = DateTime.UtcNow;
await SaveAsync(row);
Snackbar.Add(T("Datei hochgeladen.", "File uploaded."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Upload fehlgeschlagen", "Upload failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private string T(string german, string english) => UiText.Text(german, english);
private sealed class ManualImportRow
{
public int Id { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public bool IsActive { get; set; }
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
}
}