Compare commits

...

2 Commits

Author SHA1 Message Date
Tokk 9003396ca8 Möglichkeit, Graph in Vollbild zu betrachten 2026-05-29 16:22:54 +02:00
Tokk 80b74d1ded Excel-Import 2026-05-29 16:10:20 +02:00
6 changed files with 259 additions and 49 deletions
+53 -34
View File
@@ -21,42 +21,61 @@
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script>
window.drawSankeyChart = function (elementId, dataArray) {
google.charts.load('current', {'packages':['sankey']});
google.charts.setOnLoadCallback(function() {
var data = new google.visualization.DataTable();
data.addColumn('string', 'Von');
data.addColumn('string', 'Nach');
data.addColumn('number', 'Betrag');
data.addRows(dataArray);
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script>
// Wir merken uns die Daten global für den Vollbild-Wechsel
window._sankeyData = null;
window._sankeyElementId = null;
var options = {
backgroundColor: 'transparent', // Wichtig für den Dark-Mode
sankey: {
node: {
nodePadding: 20,
// Ein paar moderne, kräftige Farben
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'],
label: {
color: '#ffffff', // Weiße Schrift, damit sie auf dem dunklen Hintergrund lesbar bleibt
fontName: 'Urbanist', // Wir greifen deinen Font auf!
fontSize: 14,
bold: true
}
},
link: {
colorMode: 'gradient' // Sorgt für den fließenden, modernen Look
}
}
};
window.drawSankeyChart = function (elementId, dataArray) {
window._sankeyData = dataArray;
window._sankeyElementId = elementId;
var chart = new google.visualization.Sankey(document.getElementById(elementId));
chart.draw(data, options);
});
};
</script>
google.charts.load('current', {'packages':['sankey']});
google.charts.setOnLoadCallback(function() {
var data = new google.visualization.DataTable();
data.addColumn('string', 'Von');
data.addColumn('string', 'Nach');
data.addColumn('number', 'Betrag');
data.addRows(dataArray);
var options = {
backgroundColor: 'transparent',
sankey: {
node: {
nodePadding: 20,
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'],
label: { color: '#ffffff', fontName: 'Urbanist', fontSize: 14, bold: true }
},
link: { colorMode: 'gradient' }
}
};
var chart = new google.visualization.Sankey(document.getElementById(elementId));
chart.draw(data, options);
});
};
// NEU: Wenn sich das Fenster (oder Vollbild) ändert, zeichne neu!
window.addEventListener('resize', function() {
if (window._sankeyData && window._sankeyElementId && document.getElementById(window._sankeyElementId)) {
clearTimeout(window._resizeTimer);
window._resizeTimer = setTimeout(function() {
window.drawSankeyChart(window._sankeyElementId, window._sankeyData);
}, 100); // Minimaler Delay für weiche Übergänge
}
});
// NEU: Die Fullscreen-Umschaltung
window.toggleFullscreen = function (elementId) {
var elem = document.getElementById(elementId);
if (!document.fullscreenElement) {
elem.requestFullscreen().catch(err => console.error(err));
} else {
document.exitFullscreen();
}
};
</script>
</head>
<body style="background-color: var(--mud-palette-background); color: var(--mud-palette-text-primary);"> <Routes @rendermode="InteractiveServer" />
+46 -14
View File
@@ -5,6 +5,20 @@
@inject WorkspaceService DbService
@inject IJSRuntime JSRuntime
<style>
/* Greift NUR, wenn der Container im Vollbildmodus ist */
#sankey-fullscreen-container:fullscreen {
background-color: var(--mud-palette-background);
padding: 2rem;
overflow-y: auto;
}
/* Das Diagramm im Vollbild massiv vergrößern */
#sankey-fullscreen-container:fullscreen #sankey-chart {
height: calc(100vh - 120px) !important;
}
</style>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8 mb-8">
<MudText Typo="Typo.h4" Class="mb-6">Daten-Auswertung</MudText>
@@ -62,22 +76,34 @@
<MudItem xs="12">
<MudPaper Class="pa-6 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">Geldfluss (Sankey-Diagramm)</MudText>
<MudSelect T="string"
Label="Sichtweise (Klick-Reihenfolge bestimmt den Fluss)"
MultiSelection="true"
SelectedValues="_selectedChartDimensions"
SelectedValuesChanged="OnChartDimensionsChanged"
Variant="Variant.Outlined"
Class="mb-6">
@foreach (var dim in _selectedWorkspace.Dimensions)
{
<MudSelectItem T="string" Value="@dim.Name">@dim.Name</MudSelectItem>
}
</MudSelect>
<div class="d-flex justify-space-between align-center mb-4">
<MudText Typo="Typo.h6">Geldfluss (Sankey-Diagramm)</MudText>
<MudTooltip Text="Vollbild umschalten">
<MudIconButton Icon="@Icons.Material.Filled.Fullscreen"
OnClick="ToggleFullscreen"
Color="Color.Default" />
</MudTooltip>
</div>
<div id="sankey-chart" style="width: 100%; height: 400px;"></div>
<div id="sankey-fullscreen-container">
<MudSelect T="string"
Label="Sichtweise (Klick-Reihenfolge bestimmt den Fluss)"
MultiSelection="true"
SelectedValues="_selectedChartDimensions"
SelectedValuesChanged="OnChartDimensionsChanged"
Variant="Variant.Outlined"
Class="mb-6">
@foreach (var dim in _selectedWorkspace.Dimensions)
{
<MudSelectItem T="string" Value="@dim.Name">@dim.Name</MudSelectItem>
}
</MudSelect>
<div id="sankey-chart" style="width: 100%; height: 400px;"></div>
</div>
</MudPaper>
</MudItem>
@@ -281,4 +307,10 @@
// Ein winziger Delay stellt sicher, dass Blazor das UI fertig gerendert hat
await JSRuntime.InvokeVoidAsync("drawSankeyChart", "sankey-chart", _sankeyData);
}
private async Task ToggleFullscreen()
{
// Wir übergeben die ID unseres neuen Containers
await JSRuntime.InvokeVoidAsync("toggleFullscreen", "sankey-fullscreen-container");
}
}
+3 -1
View File
@@ -55,7 +55,9 @@
</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Color="Color.Default" />
<MudIconButton Icon="@Icons.Material.Filled.Settings"
Color="Color.Default"
Href="@($"/workspace/{ws.Id}")" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
+114
View File
@@ -0,0 +1,114 @@
@page "/workspace/{*WorkspaceId}"
@using ZahlenAnalyse.Web.Models
@using ZahlenAnalyse.Web.Services
@inject WorkspaceService DbService
@inject ISnackbar Snackbar
@inject NavigationManager NavManager
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8 mb-8">
@if (_workspace == null)
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudText Typo="Typo.h3" Class="mb-6">@_workspace.Name</MudText>
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-6" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">Daten importieren</MudText>
<MudText Typo="Typo.body2" Class="mb-4">Lade eine Excel-Datei hoch, um Fakten in diesen Workspace zu importieren.</MudText>
<MudFileUpload T="IBrowserFile" FilesChanged="@(e => UploadFile(e))" Accept=".xlsx">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Excel hochladen
</MudButton>
</CustomContent>
</MudFileUpload>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-6" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">Dimensionen</MudText>
<MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (var dim in _workspace.Dimensions)
{
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Info">@dim.Name</MudChip>
}
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-6" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">Letzte Importe / Einträge</MudText>
<MudTable Items="_recentFakten" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>Datum</MudTh>
<MudTh>Betrag</MudTh>
<MudTh>Details</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Date.ToShortDateString()</MudTd>
<MudTd>@context.Amount.ToString("N2") €</MudTd>
<MudTd>@string.Join(" | ", context.Dimensions.Values)</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>
}
</MudContainer>
@code {
[Parameter] public string WorkspaceId { get; set; } = string.Empty;
private Workspace? _workspace;
private List<AnalysisFakt> _recentFakten = new();
protected override async Task OnInitializedAsync()
{
_workspace = await DbService.GetWorkspaceAsync(WorkspaceId);
if (_workspace != null)
{
_recentFakten = await DbService.GetFaktenForWorkspaceAsync(WorkspaceId);
_recentFakten = _recentFakten.Take(5).ToList();
}
}
private async Task UploadFile(Microsoft.AspNetCore.Components.Forms.IBrowserFile e)
{
var file = e;
if (file == null) return;
try
{
// 1. Datenstrom vom Browser öffnen
using var browserStream = file.OpenReadStream(maxAllowedSize: 1024 * 1024 * 10); // 10MB
// 2. Den Stream komplett in den "Eimer" (Arbeitsspeicher) laden
using var memoryStream = new MemoryStream();
await browserStream.CopyToAsync(memoryStream);
// 3. WICHTIG: Den Lese-Zeiger im Eimer wieder nach ganz oben setzen!
memoryStream.Position = 0;
// 4. Den fertigen MemoryStream an den Service übergeben
await DbService.ImportFromExcelAsync(WorkspaceId, memoryStream);
Snackbar.Add("Import erfolgreich!", Severity.Success);
// Die Tabelle mit den neuen Daten aktualisieren
_recentFakten = (await DbService.GetFaktenForWorkspaceAsync(WorkspaceId)).Take(5).ToList();
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Import: {ex.Message}", Severity.Error);
}
}
}
+41
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Components.Authorization;
using Raven.Client.Documents;
using ZahlenAnalyse.Web.Models;
using ExcelDataReader;
using System.Data;
namespace ZahlenAnalyse.Web.Services;
@@ -118,4 +120,43 @@ public class WorkspaceService
.OrderByDescending(f => f.Date)
.ToListAsync();
}
public async Task ImportFromExcelAsync(string workspaceId, Stream fileStream)
{
// Encoding für deutsche Excel-Dateien
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
using var reader = ExcelReaderFactory.CreateReader(fileStream);
var result = reader.AsDataSet(new ExcelDataSetConfiguration() {
ConfigureDataTable = (_) => new ExcelDataTableConfiguration() { UseHeaderRow = true }
});
var dataTable = result.Tables[0];
using var session = _store.OpenAsyncSession();
foreach (DataRow row in dataTable.Rows)
{
var fakt = new AnalysisFakt
{
WorkspaceId = workspaceId,
Date = Convert.ToDateTime(row["Datum"]),
Amount = Convert.ToDecimal(row["Betrag"]),
// Hier mappen wir dynamisch die Dimensionen
Dimensions = new Dictionary<string, string>()
};
// Wir gehen davon aus, dass deine Excel-Spaltennamen
// den Dimensions-Namen entsprechen
foreach (var col in dataTable.Columns.Cast<DataColumn>().Skip(2)) // Datum/Betrag überspringen
{
fakt.Dimensions[col.ColumnName] = row[col.ColumnName].ToString() ?? "";
}
await EnrichWithAuditDataAsync(fakt);
await session.StoreAsync(fakt);
}
await session.SaveChangesAsync();
}
}
+2
View File
@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="ExcelDataReader" Version="3.8.0" />
<PackageReference Include="ExcelDataReader.DataSet" Version="3.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.5.0" />
<PackageReference Include="RavenDB.Client" Version="7.2.2" />