Diagram
This commit is contained in:
@@ -20,6 +20,43 @@
|
|||||||
<ImportMap />
|
<ImportMap />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
<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);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var chart = new google.visualization.Sankey(document.getElementById(elementId));
|
||||||
|
chart.draw(data, options);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
@page "/auswertung"
|
||||||
|
@page "/auswertung/{*WorkspaceId}"
|
||||||
|
@using ZahlenAnalyse.Web.Models
|
||||||
|
@using ZahlenAnalyse.Web.Services
|
||||||
|
@inject WorkspaceService DbService
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8 mb-8">
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-6">Daten-Auswertung</MudText>
|
||||||
|
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-6 mb-6" Elevation="1">
|
||||||
|
<MudSelect T="Workspace"
|
||||||
|
Label="Workspace auswählen"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Value="_selectedWorkspace"
|
||||||
|
ValueChanged="OnWorkspaceSelected"
|
||||||
|
ToStringFunc="@(w => w?.Name)">
|
||||||
|
@foreach (var ws in _workspaces)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="ws">@ws.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_selectedWorkspace != null)
|
||||||
|
{
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudDataGrid Items="@_fakten"
|
||||||
|
Groupable="true"
|
||||||
|
Filterable="true"
|
||||||
|
Hideable="true"
|
||||||
|
Elevation="1">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.Date" Title="Datum" Format="dd.MM.yyyy" />
|
||||||
|
|
||||||
|
@foreach (var dim in _selectedWorkspace.Dimensions)
|
||||||
|
{
|
||||||
|
if (_dimensionMaxLevels.TryGetValue(dim.Name, out int maxDepth))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < maxDepth; i++)
|
||||||
|
{
|
||||||
|
// WICHTIG für C# Closures in Schleifen: Den Index in einer lokalen Variable fangen!
|
||||||
|
int levelIndex = i;
|
||||||
|
string title = maxDepth == 1 ? dim.Name : $"{dim.Name} (Ebene {levelIndex + 1})";
|
||||||
|
|
||||||
|
<PropertyColumn Property="@(x => GetDimensionLevel(x, dim.Name, levelIndex))" Title="@title" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<PropertyColumn Property="x => x.Amount" Title="Betrag" AggregateDefinition="_sumAggregation" Format="N2" />
|
||||||
|
</Columns>
|
||||||
|
</MudDataGrid>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<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 id="sankey-chart" style="width: 100%; height: 400px;"></div>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
</MudGrid>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? WorkspaceId { get; set; }
|
||||||
|
|
||||||
|
private List<Workspace> _workspaces = new();
|
||||||
|
private Workspace? _selectedWorkspace;
|
||||||
|
private List<AnalysisFakt> _fakten = new();
|
||||||
|
private bool _isLoading = true;
|
||||||
|
|
||||||
|
private List<object[]> _sankeyData = new();
|
||||||
|
private IReadOnlyCollection<string> _selectedChartDimensions = new List<string>();
|
||||||
|
private Dictionary<string, int> _dimensionMaxLevels = new();
|
||||||
|
|
||||||
|
|
||||||
|
private AggregateDefinition<AnalysisFakt> _sumAggregation = new()
|
||||||
|
{
|
||||||
|
Type = AggregateType.Sum,
|
||||||
|
DisplayFormat = "Summe: {value} €"
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_workspaces = await DbService.GetWorkspacesForUserAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(WorkspaceId))
|
||||||
|
{
|
||||||
|
var preselected = _workspaces.FirstOrDefault(w => w.Id == WorkspaceId);
|
||||||
|
if (preselected != null)
|
||||||
|
{
|
||||||
|
await OnWorkspaceSelected(preselected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnWorkspaceSelected(Workspace ws)
|
||||||
|
{
|
||||||
|
_selectedWorkspace = ws;
|
||||||
|
_fakten = await DbService.GetFaktenForWorkspaceAsync(ws.Id!);
|
||||||
|
|
||||||
|
// Berechne die maximale Hierarchie-Tiefe für jede Dimension
|
||||||
|
_dimensionMaxLevels.Clear();
|
||||||
|
foreach (var dim in ws.Dimensions)
|
||||||
|
{
|
||||||
|
int maxDepth = 1;
|
||||||
|
foreach (var f in _fakten)
|
||||||
|
{
|
||||||
|
var val = GetDimensionValue(f, dim.Name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(val))
|
||||||
|
{
|
||||||
|
var parts = val.Split(new[] { " / " }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length > maxDepth) maxDepth = parts.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_dimensionMaxLevels[dim.Name] = maxDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedChartDimensions = ws.Dimensions.Take(2).Select(d => d.Name).ToList();
|
||||||
|
|
||||||
|
await UpdateChartDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDimensionLevel(AnalysisFakt fakt, string dimName, int level)
|
||||||
|
{
|
||||||
|
var val = GetDimensionValue(fakt, dimName);
|
||||||
|
if (string.IsNullOrWhiteSpace(val)) return "-";
|
||||||
|
|
||||||
|
var parts = val.Split(new[] { " / " }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Wenn dieser spezielle Datensatz nicht so tief verschachtelt ist, gib einen Strich zurück
|
||||||
|
return parts.Length > level ? parts[level].Trim() : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnChartDimensionsChanged(IReadOnlyCollection<string> values)
|
||||||
|
{
|
||||||
|
_selectedChartDimensions = values;
|
||||||
|
await UpdateChartDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsmethode, um das Dictionary im DataGrid sauber darzustellen
|
||||||
|
private string GetDimensionValue(AnalysisFakt fakt, string dimensionName)
|
||||||
|
{
|
||||||
|
return fakt.Dimensions.TryGetValue(dimensionName, out var value) ? value : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> _pathLabels = new();
|
||||||
|
private HashSet<string> _usedLabels = new();
|
||||||
|
|
||||||
|
private async Task UpdateChartDataAsync()
|
||||||
|
{
|
||||||
|
_sankeyData.Clear();
|
||||||
|
_pathLabels.Clear(); // Wichtig: Beim Neuzeichnen zurücksetzen
|
||||||
|
_usedLabels.Clear();
|
||||||
|
|
||||||
|
if (!_fakten.Any() || _selectedWorkspace == null || !_selectedChartDimensions.Any())
|
||||||
|
{
|
||||||
|
await DrawChartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedDims = _selectedChartDimensions.ToList();
|
||||||
|
|
||||||
|
// Den Workspace-Namen als allerersten Knoten generieren
|
||||||
|
var workspaceLabel = GetUniqueLabel(_selectedWorkspace.Name, _selectedWorkspace.Name);
|
||||||
|
|
||||||
|
foreach (var fakt in _fakten)
|
||||||
|
{
|
||||||
|
var currentFullPath = _selectedWorkspace.Name;
|
||||||
|
var currentSourceLabel = workspaceLabel;
|
||||||
|
|
||||||
|
foreach (var dimName in selectedDims)
|
||||||
|
{
|
||||||
|
var val = GetDimensionValue(fakt, dimName);
|
||||||
|
|
||||||
|
var levels = string.IsNullOrWhiteSpace(val)
|
||||||
|
? new[] { $"Ohne {dimName}" }
|
||||||
|
: val.Split(new[] { " / " }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
|
||||||
|
|
||||||
|
foreach (var level in levels)
|
||||||
|
{
|
||||||
|
// Der logische, lange Pfad (z.B. "Workspace / Italien / Maut")
|
||||||
|
var targetFullPath = $"{currentFullPath} / {level}";
|
||||||
|
|
||||||
|
// Wir holen uns den kurzen Namen (z.B. "Maut") inkl. nötiger unsichtbarer Leerzeichen
|
||||||
|
var targetLabel = GetUniqueLabel(targetFullPath, level);
|
||||||
|
|
||||||
|
AddSankeyLink(currentSourceLabel, targetLabel, fakt.Amount);
|
||||||
|
|
||||||
|
currentFullPath = targetFullPath;
|
||||||
|
currentSourceLabel = targetLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrawChartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NEU: Die Magie, die unsichtbare Leerzeichen für eindeutige IDs anhängt ---
|
||||||
|
private string GetUniqueLabel(string fullPath, string shortName)
|
||||||
|
{
|
||||||
|
// Haben wir für exakt diesen Pfad (z.B. Italien/Maut) schon ein Label?
|
||||||
|
if (_pathLabels.TryGetValue(fullPath, out var existingLabel))
|
||||||
|
{
|
||||||
|
return existingLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
string candidate = shortName;
|
||||||
|
|
||||||
|
// Solange IRGENDEIN anderer Pfad dieses Label optisch schon nutzt,
|
||||||
|
// hängen wir ein unsichtbares Leerzeichen (\u200B) an!
|
||||||
|
while (_usedLabels.Contains(candidate))
|
||||||
|
{
|
||||||
|
candidate += "\u200B";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichern und zurückgeben
|
||||||
|
_pathLabels[fullPath] = candidate;
|
||||||
|
_usedLabels.Add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSankeyLink ist wieder ganz simpel und nimmt nur Strings
|
||||||
|
private void AddSankeyLink(string source, string target, decimal amount)
|
||||||
|
{
|
||||||
|
var existing = _sankeyData.FirstOrDefault(d => (string)d[0] == source && (string)d[1] == target);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing[2] = (decimal)existing[2] + amount;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_sankeyData.Add(new object[] { source, target, amount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrawChartAsync()
|
||||||
|
{
|
||||||
|
// Ein winziger Delay stellt sicher, dass Blazor das UI fertig gerendert hat
|
||||||
|
await Task.Delay(50);
|
||||||
|
await JSRuntime.InvokeVoidAsync("drawSankeyChart", "sankey-chart", _sankeyData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<MudButton Href="@($"/erfassen/{ws.Id}")" Variant="Variant.Text" Color="Color.Primary">
|
<MudButton Href="@($"/erfassen/{ws.Id}")" Variant="Variant.Text" Color="Color.Primary">
|
||||||
Daten erfassen
|
Daten erfassen
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<MudButton Variant="Variant.Text" Color="Color.Secondary">
|
<MudButton Href="@($"/auswertung/{ws.Id}")" Variant="Variant.Text" Color="Color.Secondary">
|
||||||
Auswertung
|
Auswertung
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudCardActions>
|
</MudCardActions>
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ public class WorkspaceService
|
|||||||
|
|
||||||
public async Task<List<Workspace>> GetWorkspacesForUserAsync()
|
public async Task<List<Workspace>> GetWorkspacesForUserAsync()
|
||||||
{
|
{
|
||||||
// Hier können wir jetzt auch die OwnerId direkt aus dem Token ziehen!
|
|
||||||
// Du musst sie nicht mehr von der UI aus übergeben.
|
|
||||||
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
||||||
var ownerId = await GetUserIdAsync();
|
var ownerId = await GetUserIdAsync();
|
||||||
|
|
||||||
@@ -107,4 +106,16 @@ public class WorkspaceService
|
|||||||
await session.StoreAsync(fakt);
|
await session.StoreAsync(fakt);
|
||||||
await session.SaveChangesAsync();
|
await session.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<AnalysisFakt>> GetFaktenForWorkspaceAsync(string workspaceId)
|
||||||
|
{
|
||||||
|
using var session = _store.OpenAsyncSession();
|
||||||
|
var ownerId = await GetUserIdAsync();
|
||||||
|
|
||||||
|
|
||||||
|
return await session.Query<AnalysisFakt>()
|
||||||
|
.Where(f => f.WorkspaceId == workspaceId && f.OwnerId == ownerId)
|
||||||
|
.OrderByDescending(f => f.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user