From 852463150886144d4700589f8a3c2a9ccad49f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 14:00:44 +0000 Subject: [PATCH] Convert TrafagSalesExporter from console app to Blazor Server app with MudBlazor UI - Replaced console app with .NET 8 Blazor Server architecture - Added EF Core SQLite database (trafag_exporter.db) with auto-seed data - Models: HanaServer, Site, SharePointConfig, ExportSettings, ExportLog, SalesRecord - Services: HanaQueryService (with configurable dateFilter), ExcelExportService, SharePointUploadService, ExportOrchestrationService (with live status events), TimerBackgroundService (scheduled daily export) - MudBlazor UI pages: Dashboard (export status + manual trigger), Standorte (HANA server + site CRUD), Settings (SharePoint + timer config), Logs (filtered view) - SAP HANA queries unchanged (INV + CRN with exact SAP B1 table joins) - SharePoint upload via Microsoft Graph with app registration auth https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj --- TrafagSalesExporter/Components/App.razor | 18 ++ .../Components/Layout/MainLayout.razor | 38 +++ .../Components/Layout/NavMenu.razor | 14 + .../Components/Pages/Dashboard.razor | 193 +++++++++++ .../Components/Pages/Logs.razor | 134 ++++++++ .../Components/Pages/Settings.razor | 164 ++++++++++ .../Components/Pages/Standorte.razor | 299 ++++++++++++++++++ TrafagSalesExporter/Components/Routes.razor | 6 + TrafagSalesExporter/Components/_Imports.razor | 9 + TrafagSalesExporter/Data/AppDbContext.cs | 51 +++ TrafagSalesExporter/Models/ExportLog.cs | 21 ++ TrafagSalesExporter/Models/ExportSettings.cs | 10 + TrafagSalesExporter/Models/HanaServer.cs | 20 ++ .../Models/SharePointConfig.cs | 11 + TrafagSalesExporter/Models/Site.cs | 25 ++ TrafagSalesExporter/Program.cs | 122 ++----- .../Services/ExportOrchestrationService.cs | 163 ++++++++++ .../Services/HanaQueryService.cs | 22 +- .../Services/SharePointUploadService.cs | 41 +-- .../Services/TimerBackgroundService.cs | 67 ++++ .../TrafagSalesExporter.csproj | 18 +- TrafagSalesExporter/appsettings.json | 32 +- TrafagSalesExporter/wwwroot/css/app.css | 3 + 23 files changed, 1327 insertions(+), 154 deletions(-) create mode 100644 TrafagSalesExporter/Components/App.razor create mode 100644 TrafagSalesExporter/Components/Layout/MainLayout.razor create mode 100644 TrafagSalesExporter/Components/Layout/NavMenu.razor create mode 100644 TrafagSalesExporter/Components/Pages/Dashboard.razor create mode 100644 TrafagSalesExporter/Components/Pages/Logs.razor create mode 100644 TrafagSalesExporter/Components/Pages/Settings.razor create mode 100644 TrafagSalesExporter/Components/Pages/Standorte.razor create mode 100644 TrafagSalesExporter/Components/Routes.razor create mode 100644 TrafagSalesExporter/Components/_Imports.razor create mode 100644 TrafagSalesExporter/Data/AppDbContext.cs create mode 100644 TrafagSalesExporter/Models/ExportLog.cs create mode 100644 TrafagSalesExporter/Models/ExportSettings.cs create mode 100644 TrafagSalesExporter/Models/HanaServer.cs create mode 100644 TrafagSalesExporter/Models/SharePointConfig.cs create mode 100644 TrafagSalesExporter/Models/Site.cs create mode 100644 TrafagSalesExporter/Services/ExportOrchestrationService.cs create mode 100644 TrafagSalesExporter/Services/TimerBackgroundService.cs create mode 100644 TrafagSalesExporter/wwwroot/css/app.css diff --git a/TrafagSalesExporter/Components/App.razor b/TrafagSalesExporter/Components/App.razor new file mode 100644 index 0000000..9ec705e --- /dev/null +++ b/TrafagSalesExporter/Components/App.razor @@ -0,0 +1,18 @@ + + + + + + Trafag Sales Exporter + + + + + + + + + + + + diff --git a/TrafagSalesExporter/Components/Layout/MainLayout.razor b/TrafagSalesExporter/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..ed0b75a --- /dev/null +++ b/TrafagSalesExporter/Components/Layout/MainLayout.razor @@ -0,0 +1,38 @@ +@inherits LayoutComponentBase + + + + + + + + + + Trafag Sales Exporter + + + + + + + + @Body + + + +@code { + private bool _drawerOpen = true; + + private readonly MudTheme _theme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#1565C0", + Secondary = "#00897B", + AppbarBackground = "#1565C0" + } + }; + + private void ToggleDrawer() => _drawerOpen = !_drawerOpen; +} diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..27b1476 --- /dev/null +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -0,0 +1,14 @@ + + + Dashboard + + + Standorte + + + Settings + + + Logs + + diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..d8241d5 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -0,0 +1,193 @@ +@page "/" +@using Microsoft.EntityFrameworkCore +@using TrafagSalesExporter.Data +@using TrafagSalesExporter.Services +@inject IDbContextFactory DbFactory +@inject ExportOrchestrationService Orchestrator +@inject TimerBackgroundService TimerService +@inject ISnackbar Snackbar +@implements IDisposable + +Dashboard + +Dashboard + + + + + Alle exportieren + + + @if (TimerService.NextRun < DateTime.MaxValue) + { + + @($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}") + } + else + { + + @("Timer deaktiviert") + } + + + + + + + Land + TSC + Schema + Server + Status + Zeilen + Letzter Lauf + Dauer + Aktion + + + @context.Land + @context.TSC + @context.Schema + @context.ServerName + + @if (Orchestrator.IsExporting(context.SiteId)) + { + + @Orchestrator.GetExportStatus(context.SiteId) + } + else if (context.LastStatus == "OK") + { + + } + else if (context.LastStatus == "Error") + { + + + + } + else + { + - + } + + @(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-") + @(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-") + @(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-") + + + Export + + + + + +@code { + private List _dashboardRows = new(); + private bool _loading = true; + private bool _anyRunning; + + protected override async Task OnInitializedAsync() + { + Orchestrator.OnExportStatusChanged += HandleStatusChanged; + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + _loading = true; + using var db = await DbFactory.CreateDbContextAsync(); + + var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); + var logs = await db.ExportLogs + .GroupBy(l => l.SiteId) + .Select(g => g.OrderByDescending(l => l.Timestamp).First()) + .ToListAsync(); + + _dashboardRows = sites.Select(s => + { + var log = logs.FirstOrDefault(l => l.SiteId == s.Id); + return new DashboardRow + { + SiteId = s.Id, + Land = s.Land, + TSC = s.TSC, + Schema = s.Schema, + ServerName = s.HanaServer?.Name ?? "", + LastStatus = log?.Status ?? "", + RowCount = log?.RowCount ?? 0, + LastRun = log?.Timestamp, + DurationSeconds = log?.DurationSeconds ?? 0, + ErrorMessage = log?.ErrorMessage ?? "" + }; + }).ToList(); + + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); + _loading = false; + } + + private async Task ExportAll() + { + _anyRunning = true; + _ = Task.Run(async () => + { + await Orchestrator.ExportAllAsync(); + await InvokeAsync(async () => + { + await LoadDataAsync(); + StateHasChanged(); + }); + }); + Snackbar.Add("Export für alle Standorte gestartet", Severity.Info); + } + + private void ExportSingle(int siteId) + { + _ = Task.Run(async () => + { + await Orchestrator.ExportSiteByIdAsync(siteId); + await InvokeAsync(async () => + { + await LoadDataAsync(); + StateHasChanged(); + }); + }); + Snackbar.Add("Export gestartet", Severity.Info); + } + + private async void HandleStatusChanged() + { + await InvokeAsync(async () => + { + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); + StateHasChanged(); + if (!_anyRunning) + { + await LoadDataAsync(); + StateHasChanged(); + } + }); + } + + public void Dispose() + { + Orchestrator.OnExportStatusChanged -= HandleStatusChanged; + } + + private class DashboardRow + { + public int SiteId { get; set; } + public string Land { get; set; } = ""; + public string TSC { get; set; } = ""; + public string Schema { get; set; } = ""; + public string ServerName { get; set; } = ""; + public string LastStatus { get; set; } = ""; + public int RowCount { get; set; } + public DateTime? LastRun { get; set; } + public double DurationSeconds { get; set; } + public string ErrorMessage { get; set; } = ""; + } +} diff --git a/TrafagSalesExporter/Components/Pages/Logs.razor b/TrafagSalesExporter/Components/Pages/Logs.razor new file mode 100644 index 0000000..f97b51f --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Logs.razor @@ -0,0 +1,134 @@ +@page "/logs" +@using Microsoft.EntityFrameworkCore +@using TrafagSalesExporter.Data +@inject IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject IDialogService DialogService + +Logs + +Export Logs + + + + + @foreach (var land in _availableLands) + { + @land + } + + + OK + Error + + + + Filtern + + + + Alte Logs löschen + + + + + + + Zeitpunkt + Land + TSC + Status + Zeilen + Dauer + Dateiname + Fehler + + + @context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss") + @context.Land + @context.TSC + + @if (context.Status == "OK") + { + OK + } + else + { + Error + } + + @context.RowCount.ToString("N0") + @($"{context.DurationSeconds:F1}s") + @context.FileName + + @if (!string.IsNullOrEmpty(context.ErrorMessage)) + { + + + @context.ErrorMessage + + + } + + + + +@code { + private List _logs = new(); + private List _availableLands = new(); + private string? _filterLand; + private string? _filterStatus; + private DateTime? _filterDate; + private bool _loading = true; + + protected override async Task OnInitializedAsync() + { + using var db = await DbFactory.CreateDbContextAsync(); + _availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync(); + await LoadLogsAsync(); + } + + private async Task LoadLogsAsync() + { + _loading = true; + using var db = await DbFactory.CreateDbContextAsync(); + IQueryable query = db.ExportLogs.OrderByDescending(l => l.Timestamp); + + if (!string.IsNullOrEmpty(_filterLand)) + query = query.Where(l => l.Land == _filterLand); + + if (!string.IsNullOrEmpty(_filterStatus)) + query = query.Where(l => l.Status == _filterStatus); + + if (_filterDate.HasValue) + query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date); + + _logs = await query.Take(500).ToListAsync(); + _loading = false; + } + + private async Task ApplyFilter() + { + await LoadLogsAsync(); + } + + private async Task DeleteOldLogs() + { + var result = await DialogService.ShowMessageBox( + "Alte Logs löschen", + "Logs älter als 90 Tage löschen?", + yesText: "Löschen", cancelText: "Abbrechen"); + + if (result != true) return; + + using var db = await DbFactory.CreateDbContextAsync(); + var cutoff = DateTime.Now.AddDays(-90); + var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync(); + db.ExportLogs.RemoveRange(oldLogs); + var count = await db.SaveChangesAsync(); + await LoadLogsAsync(); + Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info); + } +} diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor new file mode 100644 index 0000000..d40e958 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Settings.razor @@ -0,0 +1,164 @@ +@page "/settings" +@using Microsoft.EntityFrameworkCore +@using TrafagSalesExporter.Data +@using TrafagSalesExporter.Services +@inject IDbContextFactory DbFactory +@inject SharePointUploadService SpService +@inject TimerBackgroundService TimerService +@inject ISnackbar Snackbar + +Settings + +Settings + +@* SharePoint Config *@ +SharePoint Konfiguration + + + + + + + + + + + + + + + + + + + + + Speichern + + + @if (_testingSp) + { + + @("Teste...") + } + else + { + @("SharePoint Verbindung testen") + } + + + + + + +@* Export Settings *@ +Export Einstellungen + + + + + + + + + + + + + + + + + Speichern + + + + + +@* Filename Preview *@ +Dateiname Vorschau + + + + Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx + + + Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx + + + +@code { + private SharePointConfig _spConfig = new(); + private ExportSettings _exportSettings = new(); + private bool _testingSp; + + protected override async Task OnInitializedAsync() + { + using var db = await DbFactory.CreateDbContextAsync(); + _spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(); + _exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); + } + + private async Task SaveSharePoint() + { + using var db = await DbFactory.CreateDbContextAsync(); + var existing = await db.SharePointConfigs.FirstOrDefaultAsync(); + if (existing is null) + { + db.SharePointConfigs.Add(_spConfig); + } + else + { + existing.SiteUrl = _spConfig.SiteUrl; + existing.ExportFolder = _spConfig.ExportFolder; + existing.TenantId = _spConfig.TenantId; + existing.ClientId = _spConfig.ClientId; + existing.ClientSecret = _spConfig.ClientSecret; + } + await db.SaveChangesAsync(); + Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success); + } + + private async Task TestSharePoint() + { + _testingSp = true; + try + { + await SpService.TestConnectionAsync( + _spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl); + Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error); + } + finally + { + _testingSp = false; + } + } + + private async Task SaveExportSettings() + { + using var db = await DbFactory.CreateDbContextAsync(); + var existing = await db.ExportSettings.FirstOrDefaultAsync(); + if (existing is null) + { + db.ExportSettings.Add(_exportSettings); + } + else + { + existing.DateFilter = _exportSettings.DateFilter; + existing.TimerHour = _exportSettings.TimerHour; + existing.TimerMinute = _exportSettings.TimerMinute; + existing.TimerEnabled = _exportSettings.TimerEnabled; + } + await db.SaveChangesAsync(); + TimerService.Recalculate(); + Snackbar.Add("Export Einstellungen gespeichert", Severity.Success); + } +} diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor new file mode 100644 index 0000000..b524454 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -0,0 +1,299 @@ +@page "/standorte" +@using Microsoft.EntityFrameworkCore +@using TrafagSalesExporter.Data +@using TrafagSalesExporter.Services +@inject IDbContextFactory DbFactory +@inject HanaQueryService HanaService +@inject ISnackbar Snackbar +@inject IDialogService DialogService + +Standorte + +Standorte + +@* HANA Server Section *@ +HANA Server + + + Server hinzufügen + + + + + Name + Host + Port + Username + Aktionen + + + @context.Name + @context.Host + @context.Port + @context.Username + + + + + + + + + +@* Sites Section *@ +Standorte (Sites) + + + Neuen Standort hinzufügen + + + + + Land + TSC + Schema + Server + Aktiv + Aktionen + + + @context.Land + @context.TSC + @context.Schema + @(context.HanaServer?.Name ?? "-") + + @if (context.IsActive) + { + + } + else + { + + } + + + + + + + + + +@* Server Dialog *@ + + + @(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten") + + + + + + + + + + Abbrechen + Speichern + + + +@* Site Dialog *@ + + + @(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten") + + + + @foreach (var s in _servers) + { + @s.Name + } + + + + + + + + Abbrechen + Speichern + + + +@code { + private List _servers = new(); + private List _sites = new(); + private HanaServer _editingServer = new(); + private Site _editingSite = new(); + private bool _serverDialogVisible; + private bool _siteDialogVisible; + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + using var db = await DbFactory.CreateDbContextAsync(); + _servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync(); + _sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync(); + } + + // Server CRUD + private void AddServer() + { + _editingServer = new HanaServer { Port = 30015 }; + _serverDialogVisible = true; + } + + private void EditServer(HanaServer server) + { + _editingServer = new HanaServer + { + Id = server.Id, + Name = server.Name, + Host = server.Host, + Port = server.Port, + Username = server.Username, + Password = server.Password + }; + _serverDialogVisible = true; + } + + private async Task SaveServer() + { + using var db = await DbFactory.CreateDbContextAsync(); + if (_editingServer.Id == 0) + { + db.HanaServers.Add(_editingServer); + } + else + { + var existing = await db.HanaServers.FindAsync(_editingServer.Id); + if (existing is not null) + { + existing.Name = _editingServer.Name; + existing.Host = _editingServer.Host; + existing.Port = _editingServer.Port; + existing.Username = _editingServer.Username; + existing.Password = _editingServer.Password; + } + } + await db.SaveChangesAsync(); + _serverDialogVisible = false; + await LoadDataAsync(); + Snackbar.Add("Server gespeichert", Severity.Success); + } + + private async Task DeleteServer(HanaServer server) + { + var result = await DialogService.ShowMessageBox( + "Server löschen", + $"Server '{server.Name}' wirklich löschen?", + yesText: "Löschen", cancelText: "Abbrechen"); + + if (result != true) return; + + using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.HanaServers.FindAsync(server.Id); + if (entity is not null) + { + db.HanaServers.Remove(entity); + await db.SaveChangesAsync(); + } + await LoadDataAsync(); + Snackbar.Add("Server gelöscht", Severity.Info); + } + + private async Task TestServerConnection(HanaServer server) + { + try + { + await Task.Run(() => HanaService.TestConnection(server.Host, server.Port, server.Username, server.Password)); + Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich!", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error); + } + } + + // Site CRUD + private void AddSite() + { + _editingSite = new Site + { + IsActive = true, + HanaServerId = _servers.FirstOrDefault()?.Id ?? 0 + }; + _siteDialogVisible = true; + } + + private void EditSite(Site site) + { + _editingSite = new Site + { + Id = site.Id, + HanaServerId = site.HanaServerId, + Schema = site.Schema, + TSC = site.TSC, + Land = site.Land, + IsActive = site.IsActive + }; + _siteDialogVisible = true; + } + + private async Task SaveSite() + { + using var db = await DbFactory.CreateDbContextAsync(); + if (_editingSite.Id == 0) + { + db.Sites.Add(_editingSite); + } + else + { + var existing = await db.Sites.FindAsync(_editingSite.Id); + if (existing is not null) + { + existing.HanaServerId = _editingSite.HanaServerId; + existing.Schema = _editingSite.Schema; + existing.TSC = _editingSite.TSC; + existing.Land = _editingSite.Land; + existing.IsActive = _editingSite.IsActive; + } + } + await db.SaveChangesAsync(); + _siteDialogVisible = false; + await LoadDataAsync(); + Snackbar.Add("Standort gespeichert", Severity.Success); + } + + private async Task DeleteSite(Site site) + { + var result = await DialogService.ShowMessageBox( + "Standort löschen", + $"Standort '{site.Land}' wirklich löschen?", + yesText: "Löschen", cancelText: "Abbrechen"); + + if (result != true) return; + + using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Sites.FindAsync(site.Id); + if (entity is not null) + { + db.Sites.Remove(entity); + await db.SaveChangesAsync(); + } + await LoadDataAsync(); + Snackbar.Add("Standort gelöscht", Severity.Info); + } +} diff --git a/TrafagSalesExporter/Components/Routes.razor b/TrafagSalesExporter/Components/Routes.razor new file mode 100644 index 0000000..faa2a8c --- /dev/null +++ b/TrafagSalesExporter/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/TrafagSalesExporter/Components/_Imports.razor b/TrafagSalesExporter/Components/_Imports.razor new file mode 100644 index 0000000..5bef312 --- /dev/null +++ b/TrafagSalesExporter/Components/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using TrafagSalesExporter.Components +@using TrafagSalesExporter.Components.Layout +@using TrafagSalesExporter.Models diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs new file mode 100644 index 0000000..c045206 --- /dev/null +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet HanaServers => Set(); + public DbSet Sites => Set(); + public DbSet SharePointConfigs => Set(); + public DbSet ExportSettings => Set(); + public DbSet ExportLogs => Set(); + + public static void SeedIfEmpty(AppDbContext db) + { + if (db.HanaServers.Any()) return; + + var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" }; + var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" }; + db.HanaServers.AddRange(serverInternal, serverIndia); + db.SaveChanges(); + + db.Sites.AddRange( + new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true }, + new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true }, + new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true }, + new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true } + ); + + db.SharePointConfigs.Add(new SharePointConfig + { + SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform", + ExportFolder = "/Shared Documents/Exports/", + TenantId = "", + ClientId = "", + ClientSecret = "" + }); + + db.ExportSettings.Add(new ExportSettings + { + DateFilter = "2025-01-01", + TimerHour = 3, + TimerMinute = 0, + TimerEnabled = true + }); + + db.SaveChanges(); + } +} diff --git a/TrafagSalesExporter/Models/ExportLog.cs b/TrafagSalesExporter/Models/ExportLog.cs new file mode 100644 index 0000000..7a9e88b --- /dev/null +++ b/TrafagSalesExporter/Models/ExportLog.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace TrafagSalesExporter.Models; + +public class ExportLog +{ + public int Id { get; set; } + public DateTime Timestamp { get; set; } + public int SiteId { get; set; } + + [ForeignKey(nameof(SiteId))] + public Site? Site { get; set; } + + public string Land { get; set; } = string.Empty; + public string TSC { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public int RowCount { get; set; } + public string? ErrorMessage { get; set; } + public string FileName { get; set; } = string.Empty; + public double DurationSeconds { get; set; } +} diff --git a/TrafagSalesExporter/Models/ExportSettings.cs b/TrafagSalesExporter/Models/ExportSettings.cs new file mode 100644 index 0000000..4fdd204 --- /dev/null +++ b/TrafagSalesExporter/Models/ExportSettings.cs @@ -0,0 +1,10 @@ +namespace TrafagSalesExporter.Models; + +public class ExportSettings +{ + public int Id { get; set; } + public string DateFilter { get; set; } = "2025-01-01"; + public int TimerHour { get; set; } = 3; + public int TimerMinute { get; set; } + public bool TimerEnabled { get; set; } = true; +} diff --git a/TrafagSalesExporter/Models/HanaServer.cs b/TrafagSalesExporter/Models/HanaServer.cs new file mode 100644 index 0000000..157953d --- /dev/null +++ b/TrafagSalesExporter/Models/HanaServer.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrafagSalesExporter.Models; + +public class HanaServer +{ + public int Id { get; set; } + + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string Host { get; set; } = string.Empty; + + public int Port { get; set; } = 30015; + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Models/SharePointConfig.cs b/TrafagSalesExporter/Models/SharePointConfig.cs new file mode 100644 index 0000000..f973215 --- /dev/null +++ b/TrafagSalesExporter/Models/SharePointConfig.cs @@ -0,0 +1,11 @@ +namespace TrafagSalesExporter.Models; + +public class SharePointConfig +{ + public int Id { get; set; } + public string SiteUrl { get; set; } = string.Empty; + public string ExportFolder { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs new file mode 100644 index 0000000..42df2d3 --- /dev/null +++ b/TrafagSalesExporter/Models/Site.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TrafagSalesExporter.Models; + +public class Site +{ + public int Id { get; set; } + + public int HanaServerId { get; set; } + + [ForeignKey(nameof(HanaServerId))] + public HanaServer? HanaServer { get; set; } + + [Required] + public string Schema { get; set; } = string.Empty; + + [Required] + public string TSC { get; set; } = string.Empty; + + [Required] + public string Land { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; +} diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 5ca6023..054df35 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -1,100 +1,44 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.EntityFrameworkCore; +using MudBlazor.Services; +using TrafagSalesExporter.Data; using TrafagSalesExporter.Services; -namespace TrafagSalesExporter; +var builder = WebApplication.CreateBuilder(args); -internal static class Program +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddMudServices(); + +builder.Services.AddDbContextFactory(options => + options.UseSqlite("Data Source=trafag_exporter.db")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) { - private static async Task Main() - { - var config = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) - .Build(); - - var appConfig = config.Get() ?? throw new InvalidOperationException("Konfiguration konnte nicht geladen werden."); - - var hanaService = new HanaQueryService(); - var excelService = new ExcelExportService(); - var sharePointService = new SharePointUploadService( - appConfig.SharePoint.TenantId, - appConfig.SharePoint.ClientId, - appConfig.SharePoint.ClientSecret, - appConfig.SharePoint.SiteUrl, - appConfig.SharePoint.ExportFolder); - - var outputDir = Path.Combine(AppContext.BaseDirectory, "output"); - - foreach (var site in appConfig.Sites) - { - try - { - Log($"Starte Standort: {site.Land} ({site.Schema})"); - - if (!appConfig.HanaServers.TryGetValue(site.Server, out var serverConfig)) - { - throw new InvalidOperationException($"HANA Server-Konfiguration '{site.Server}' nicht gefunden."); - } - - var records = hanaService.GetSalesRecords( - serverConfig.Host, - serverConfig.Port, - serverConfig.Username, - serverConfig.Password, - site.Schema, - site.TSC, - site.Land); - - var filePath = excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); - Log($"Excel erzeugt: {filePath}"); - - await sharePointService.UploadAsync(site.Land, filePath); - Log($"Upload abgeschlossen: {site.Land}"); - } - catch (Exception ex) - { - Log($"Fehler bei Standort {site.Land}: {ex.Message}"); - } - } - - Log("Export beendet."); - } - - private static void Log(string message) - { - Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}"); - } + var dbFactory = scope.ServiceProvider.GetRequiredService>(); + using var db = await dbFactory.CreateDbContextAsync(); + await db.Database.EnsureCreatedAsync(); + AppDbContext.SeedIfEmpty(db); } -public class AppConfig +if (!app.Environment.IsDevelopment()) { - public Dictionary HanaServers { get; set; } = new(); - public List Sites { get; set; } = new(); - public SharePointConfig SharePoint { get; set; } = new(); - public string DateFilter { get; set; } = "2025-01-01"; + app.UseHsts(); } -public class HanaServerConfig -{ - public string Host { get; set; } = string.Empty; - public int Port { get; set; } - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; -} +app.UseStaticFiles(); +app.UseAntiforgery(); -public class SiteConfig -{ - public string Schema { get; set; } = string.Empty; - public string Server { get; set; } = string.Empty; - public string TSC { get; set; } = string.Empty; - public string Land { get; set; } = string.Empty; -} +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); -public class SharePointConfig -{ - public string SiteUrl { get; set; } = string.Empty; - public string ExportFolder { get; set; } = string.Empty; - public string TenantId { get; set; } = string.Empty; - public string ClientId { get; set; } = string.Empty; - public string ClientSecret { get; set; } = string.Empty; -} +app.Run(); diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs new file mode 100644 index 0000000..6fddbb5 --- /dev/null +++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs @@ -0,0 +1,163 @@ +using Microsoft.EntityFrameworkCore; +using System.Diagnostics; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public class ExportOrchestrationService +{ + private readonly IDbContextFactory _dbFactory; + private readonly HanaQueryService _hanaService; + private readonly ExcelExportService _excelService; + private readonly SharePointUploadService _sharePointService; + private readonly ILogger _logger; + + public event Action? OnExportStatusChanged; + + private readonly Dictionary _runningExports = new(); + private readonly object _lock = new(); + + public ExportOrchestrationService( + IDbContextFactory dbFactory, + HanaQueryService hanaService, + ExcelExportService excelService, + SharePointUploadService sharePointService, + ILogger logger) + { + _dbFactory = dbFactory; + _hanaService = hanaService; + _excelService = excelService; + _sharePointService = sharePointService; + _logger = logger; + } + + public bool IsExporting(int siteId) + { + lock (_lock) + { + return _runningExports.ContainsKey(siteId); + } + } + + public string GetExportStatus(int siteId) + { + lock (_lock) + { + return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty; + } + } + + public async Task ExportAllAsync() + { + using var db = await _dbFactory.CreateDbContextAsync(); + var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); + foreach (var site in sites) + { + await ExportSiteAsync(site); + } + } + + public async Task ExportSiteByIdAsync(int siteId) + { + using var db = await _dbFactory.CreateDbContextAsync(); + var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId); + if (site is null) return; + await ExportSiteAsync(site); + } + + private async Task ExportSiteAsync(Site site) + { + if (site.HanaServer is null) return; + + lock (_lock) + { + if (_runningExports.ContainsKey(site.Id)) return; + _runningExports[site.Id] = "HANA Abfrage..."; + } + NotifyChanged(); + + var sw = Stopwatch.StartNew(); + var log = new ExportLog + { + Timestamp = DateTime.Now, + SiteId = site.Id, + Land = site.Land, + TSC = site.TSC + }; + + try + { + using var db = await _dbFactory.CreateDbContextAsync(); + var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); + var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync(); + + UpdateStatus(site.Id, "HANA Abfrage..."); + var records = await Task.Run(() => _hanaService.GetSalesRecords( + site.HanaServer.Host, site.HanaServer.Port, + site.HanaServer.Username, site.HanaServer.Password, + site.Schema, site.TSC, site.Land, settings.DateFilter)); + + UpdateStatus(site.Id, "Excel erstellen..."); + var outputDir = Path.Combine(AppContext.BaseDirectory, "output"); + var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); + var fileName = Path.GetFileName(filePath); + + if (spConfig is not null && + !string.IsNullOrWhiteSpace(spConfig.TenantId) && + !string.IsNullOrWhiteSpace(spConfig.ClientId) && + !string.IsNullOrWhiteSpace(spConfig.ClientSecret)) + { + UpdateStatus(site.Id, "SharePoint Upload..."); + await _sharePointService.UploadAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, + spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath); + } + + sw.Stop(); + log.Status = "OK"; + log.RowCount = records.Count; + log.FileName = fileName; + log.DurationSeconds = sw.Elapsed.TotalSeconds; + + _logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s", + site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds); + } + catch (Exception ex) + { + sw.Stop(); + log.Status = "Error"; + log.ErrorMessage = ex.Message; + log.FileName = string.Empty; + log.DurationSeconds = sw.Elapsed.TotalSeconds; + + _logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC); + } + finally + { + using var db = await _dbFactory.CreateDbContextAsync(); + db.ExportLogs.Add(log); + await db.SaveChangesAsync(); + + lock (_lock) + { + _runningExports.Remove(site.Id); + } + NotifyChanged(); + } + } + + private void UpdateStatus(int siteId, string status) + { + lock (_lock) + { + _runningExports[siteId] = status; + } + NotifyChanged(); + } + + private void NotifyChanged() + { + OnExportStatusChanged?.Invoke(); + } +} diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs index 125c2ad..8872d2e 100644 --- a/TrafagSalesExporter/Services/HanaQueryService.cs +++ b/TrafagSalesExporter/Services/HanaQueryService.cs @@ -5,7 +5,8 @@ namespace TrafagSalesExporter.Services; public class HanaQueryService { - public List GetSalesRecords(string host, int port, string username, string password, string schema, string tsc, string land) + public List GetSalesRecords(string host, int port, string username, string password, + string schema, string tsc, string land, string dateFilter) { var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}"; var result = new List(); @@ -13,8 +14,8 @@ public class HanaQueryService using var connection = new HanaConnection(connectionString); connection.Open(); - var invoiceQuery = GetInvoiceQuery(schema, tsc); - var creditNoteQuery = GetCreditNoteQuery(schema, tsc); + var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter); + var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter); result.AddRange(ReadRecords(connection, invoiceQuery, land)); result.AddRange(ReadRecords(connection, creditNoteQuery, land)); @@ -31,6 +32,13 @@ public class HanaQueryService return result; } + public void TestConnection(string host, int port, string username, string password) + { + var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}"; + using var connection = new HanaConnection(connectionString); + connection.Open(); + } + private static List ReadRecords(HanaConnection connection, string query, string land) { var records = new List(); @@ -74,7 +82,7 @@ public class HanaQueryService return records; } - private static string GetInvoiceQuery(string schema, string tsc) => $@" + private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@" SELECT CURRENT_TIMESTAMP AS extraction_date, '{tsc}' AS tsc, @@ -119,10 +127,10 @@ LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" AND sup_adr.""AdresType"" = 'B' LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" -WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '2025-01-01' +WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; - private static string GetCreditNoteQuery(string schema, string tsc) => $@" + private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@" SELECT CURRENT_TIMESTAMP AS extraction_date, '{tsc}' AS tsc, @@ -162,6 +170,6 @@ LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" AND sup_adr.""AdresType"" = 'B' LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" -WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '2025-01-01' +WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; } diff --git a/TrafagSalesExporter/Services/SharePointUploadService.cs b/TrafagSalesExporter/Services/SharePointUploadService.cs index 12aae78..fdfb578 100644 --- a/TrafagSalesExporter/Services/SharePointUploadService.cs +++ b/TrafagSalesExporter/Services/SharePointUploadService.cs @@ -5,40 +5,41 @@ namespace TrafagSalesExporter.Services; public class SharePointUploadService { - private readonly GraphServiceClient _graphClient; - private readonly string _siteUrl; - private readonly string _exportFolder; - - public SharePointUploadService(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder) + public async Task UploadAsync(string tenantId, string clientId, string clientSecret, + string siteUrl, string exportFolder, string land, string localFilePath) { var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); - _graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); - _siteUrl = siteUrl; - _exportFolder = exportFolder; - } + var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); - public async Task UploadAsync(string land, string localFilePath) - { - var uri = new Uri(_siteUrl); + var uri = new Uri(siteUrl); var sitePath = uri.AbsolutePath; - var site = await _graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync(); + var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync(); if (site?.Id is null) - { throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden."); - } - var drive = await _graphClient.Sites[site.Id].Drive.GetAsync(); + var drive = await graphClient.Sites[site.Id].Drive.GetAsync(); if (drive?.Id is null) - { throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden."); - } var fileName = Path.GetFileName(localFilePath); - var folderPath = $"{_exportFolder.Trim('/').Trim()}"; + var folderPath = exportFolder.Trim('/').Trim(); var remotePath = $"{folderPath}/{land}/{fileName}"; await using var stream = File.OpenRead(localFilePath); - await _graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream); + await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream); + } + + public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) + { + var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); + + var uri = new Uri(siteUrl); + var sitePath = uri.AbsolutePath; + var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync(); + + if (site?.Id is null) + throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden."); } } diff --git a/TrafagSalesExporter/Services/TimerBackgroundService.cs b/TrafagSalesExporter/Services/TimerBackgroundService.cs new file mode 100644 index 0000000..22d9463 --- /dev/null +++ b/TrafagSalesExporter/Services/TimerBackgroundService.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; + +namespace TrafagSalesExporter.Services; + +public class TimerBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private DateTime _nextRun = DateTime.MaxValue; + + public DateTime NextRun => _nextRun; + + public TimerBackgroundService(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public void Recalculate() + { + _ = RecalculateNextRunAsync(); + } + + private async Task RecalculateNextRunAsync() + { + var dbFactory = _serviceProvider.GetRequiredService>(); + using var db = await dbFactory.CreateDbContextAsync(); + var settings = await db.ExportSettings.FirstOrDefaultAsync(); + + if (settings is null || !settings.TimerEnabled) + { + _nextRun = DateTime.MaxValue; + return; + } + + var now = DateTime.Now; + var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0); + _nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await RecalculateNextRunAsync(); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + + if (DateTime.Now < _nextRun) continue; + + _logger.LogInformation("Timer-Export gestartet um {Time}", DateTime.Now); + + try + { + var orchestrator = _serviceProvider.GetRequiredService(); + await orchestrator.ExportAllAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Timer-Export"); + } + + await RecalculateNextRunAsync(); + } + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.csproj b/TrafagSalesExporter/TrafagSalesExporter.csproj index 4d9d45a..ddd1092 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.csproj +++ b/TrafagSalesExporter/TrafagSalesExporter.csproj @@ -1,6 +1,5 @@ - + - Exe net8.0 enable enable @@ -8,17 +7,14 @@ - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - PreserveNewest - - diff --git a/TrafagSalesExporter/appsettings.json b/TrafagSalesExporter/appsettings.json index 0a5463d..0c208ae 100644 --- a/TrafagSalesExporter/appsettings.json +++ b/TrafagSalesExporter/appsettings.json @@ -1,30 +1,8 @@ { - "HanaServers": { - "Internal": { - "Host": "travtrp0", - "Port": 30015, - "Username": "", - "Password": "" - }, - "India": { - "Host": "20.197.20.60", - "Port": 30015, - "Username": "", - "Password": "" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" } - }, - "Sites": [ - { "Schema": "fr01_p", "Server": "Internal", "TSC": "TRFR", "Land": "Frankreich" }, - { "Schema": "it01_p", "Server": "Internal", "TSC": "TRIT", "Land": "Italien" }, - { "Schema": "us01_p", "Server": "Internal", "TSC": "TRUS", "Land": "USA" }, - { "Schema": "TRAFAG_LIVE", "Server": "India", "TSC": "TRIN", "Land": "Indien" } - ], - "SharePoint": { - "SiteUrl": "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform", - "ExportFolder": "/Shared Documents/Exports/", - "TenantId": "", - "ClientId": "", - "ClientSecret": "" - }, - "DateFilter": "2025-01-01" + } } diff --git a/TrafagSalesExporter/wwwroot/css/app.css b/TrafagSalesExporter/wwwroot/css/app.css new file mode 100644 index 0000000..43376d2 --- /dev/null +++ b/TrafagSalesExporter/wwwroot/css/app.css @@ -0,0 +1,3 @@ +html, body { + font-family: 'Roboto', sans-serif; +}