Compare commits
13 Commits
6d3beae249
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 83f377cfa0 | |||
| 9003396ca8 | |||
| 80b74d1ded | |||
| 12f10a44c4 | |||
| 07ab65e1d0 | |||
| 4abe66c283 | |||
| c52a00adbd | |||
| fe11b715a6 | |||
| 818377c0a8 | |||
| 2d05a18a0d | |||
| 8001ff8ced | |||
| a95ba11c16 | |||
| fda8187792 |
@@ -0,0 +1,8 @@
|
|||||||
|
# Pocket-ID
|
||||||
|
PocketId__Authority=https://pocketid.com
|
||||||
|
PocketId__ClientId=Client_ID
|
||||||
|
PocketId__ClientSecret=Client_Secret
|
||||||
|
|
||||||
|
# RavenDB
|
||||||
|
RavenDb__Urls=http://localhost:8080
|
||||||
|
RavenDb__Database=ZahlenAnalyse
|
||||||
+67
-3
@@ -5,19 +5,83 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Urbanist:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
|
||||||
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
<ResourcePreloader />
|
<ResourcePreloader />
|
||||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["ZahlenAnalyse.Web.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["ZahlenAnalyse.Web.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<HeadOutlet />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
|
|
||||||
|
<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;
|
||||||
|
|
||||||
|
window.drawSankeyChart = function (elementId, dataArray) {
|
||||||
|
window._sankeyData = dataArray;
|
||||||
|
window._sankeyElementId = elementId;
|
||||||
|
|
||||||
|
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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body style="background-color: var(--mud-palette-background); color: var(--mud-palette-text-primary);"> <Routes @rendermode="InteractiveServer" />
|
||||||
<Routes />
|
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<div class="page">
|
<MudThemeProvider Theme="@_myCustomTheme" IsDarkMode="true" />
|
||||||
<div class="sidebar">
|
<MudDialogProvider />
|
||||||
<NavMenu />
|
<MudSnackbarProvider />
|
||||||
</div>
|
<MudPopoverProvider />
|
||||||
|
|
||||||
<main>
|
<MudLayout>
|
||||||
<div class="top-row px-4">
|
<MudAppBar Elevation="1" Color="Color.Surface">
|
||||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
<MudLink Href="/" Underline="Underline.None" Color="Color.Primary" Class="d-flex align-center">
|
||||||
</div>
|
<MudIcon Icon="@Icons.Material.Filled.DataExploration" Size="Size.Large" Class="mr-2" />
|
||||||
|
<MudText Typo="Typo.h5" Style="font-weight: 700;">Zahlen-Analyse</MudText>
|
||||||
|
</MudLink>
|
||||||
|
|
||||||
<article class="content px-4">
|
<MudSpacer />
|
||||||
|
|
||||||
|
<MudButton Href="/"
|
||||||
|
StartIcon="@Icons.Material.Filled.Home"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Color="Color.Primary">
|
||||||
|
Startseite
|
||||||
|
</MudButton>
|
||||||
|
</MudAppBar>
|
||||||
|
|
||||||
|
<MudMainContent>
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Large" Class="pt-4 pb-8">
|
||||||
@Body
|
@Body
|
||||||
</article>
|
</MudContainer>
|
||||||
</main>
|
</MudMainContent>
|
||||||
</div>
|
</MudLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private MudTheme _myCustomTheme = new MudTheme()
|
||||||
|
{
|
||||||
|
Typography = new Typography()
|
||||||
|
{
|
||||||
|
Default = new DefaultTypography() { FontFamily = new[] { "Urbanist", "sans-serif" } }
|
||||||
|
},
|
||||||
|
LayoutProperties = new LayoutProperties()
|
||||||
|
{
|
||||||
|
DefaultBorderRadius = "0px"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
@page "/auswertung"
|
||||||
|
@page "/auswertung/{*WorkspaceId}"
|
||||||
|
@using ZahlenAnalyse.Web.Models
|
||||||
|
@using ZahlenAnalyse.Web.Services
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@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">
|
||||||
|
|
||||||
|
<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-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>
|
||||||
|
|
||||||
|
</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 bool _isInteractive = false;
|
||||||
|
private bool _shouldRenderChart = false;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
// Ab jetzt ist der Browser da und JavaScript ist erlaubt!
|
||||||
|
_isInteractive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isInteractive && _shouldRenderChart && !_isLoading && _selectedWorkspace != null)
|
||||||
|
{
|
||||||
|
_shouldRenderChart = false; // Flag direkt wieder wegnehmen
|
||||||
|
await DrawChartAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
_usedLabels.Clear();
|
||||||
|
|
||||||
|
if (!_fakten.Any() || _selectedWorkspace == null || !_selectedChartDimensions.Any())
|
||||||
|
{
|
||||||
|
_shouldRenderChart = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedDims = _selectedChartDimensions.ToList();
|
||||||
|
|
||||||
|
// --- 1. DURCHGANG: Gesamtsummen pro eindeutigem Pfad berechnen ---
|
||||||
|
var pathAmounts = new Dictionary<string, decimal>();
|
||||||
|
decimal totalWorkspaceAmount = 0;
|
||||||
|
|
||||||
|
foreach (var fakt in _fakten)
|
||||||
|
{
|
||||||
|
totalWorkspaceAmount += fakt.Amount;
|
||||||
|
var currentFullPath = _selectedWorkspace.Name;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var targetFullPath = $"{currentFullPath} / {level}";
|
||||||
|
|
||||||
|
if (!pathAmounts.ContainsKey(targetFullPath))
|
||||||
|
pathAmounts[targetFullPath] = 0;
|
||||||
|
|
||||||
|
pathAmounts[targetFullPath] += fakt.Amount;
|
||||||
|
currentFullPath = targetFullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hauptknoten (Workspace) mit Gesamtsumme versehen
|
||||||
|
var workspaceLabel = GetUniqueLabel(_selectedWorkspace.Name, $"{_selectedWorkspace.Name} ({totalWorkspaceAmount.ToString("N2")} €)");
|
||||||
|
|
||||||
|
// --- 2. DURCHGANG: Diagramm-Daten mit den summierten Labels bauen ---
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var targetFullPath = $"{currentFullPath} / {level}";
|
||||||
|
|
||||||
|
// Wir holen den berechneten Gesamtbetrag für diesen spezifischen Knoten
|
||||||
|
var nodeAmount = pathAmounts[targetFullPath];
|
||||||
|
var shortNameWithAmount = $"{level} ({nodeAmount.ToString("N2")} €)";
|
||||||
|
|
||||||
|
// Unser Zero-Width-Space Hack sorgt weiterhin für die Trennung im Hintergrund
|
||||||
|
var targetLabel = GetUniqueLabel(targetFullPath, shortNameWithAmount);
|
||||||
|
|
||||||
|
AddSankeyLink(currentSourceLabel, targetLabel, fakt.Amount);
|
||||||
|
|
||||||
|
currentFullPath = targetFullPath;
|
||||||
|
currentSourceLabel = targetLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_shouldRenderChart = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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()
|
||||||
|
{
|
||||||
|
if (!_isInteractive)
|
||||||
|
return;
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
@page "/erfassen"
|
||||||
|
@page "/erfassen/{*WorkspaceId}"
|
||||||
|
@using ZahlenAnalyse.Web.Models
|
||||||
|
@using ZahlenAnalyse.Web.Services
|
||||||
|
@inject WorkspaceService DbService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject NavigationManager NavManager
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8 mb-8">
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-6">Daten erfassen</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)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-6 mb-6" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-4">Basisdaten</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudDatePicker Label="Datum"
|
||||||
|
@bind-Date="_selectedDate"
|
||||||
|
Variant="Variant.Outlined" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudNumericField @bind-Value="_fakt.Amount"
|
||||||
|
Label="Betrag"
|
||||||
|
Format="N2"
|
||||||
|
Adornment="Adornment.End"
|
||||||
|
AdornmentText="€"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
HideSpinButtons="true" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6 mb-6" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-4">Kategorisierung</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
@foreach (var dim in _selectedWorkspace.Dimensions)
|
||||||
|
{
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudAutocomplete T="string"
|
||||||
|
Label="@dim.Name"
|
||||||
|
Value="_fakt.Dimensions[dim.Name]"
|
||||||
|
ValueChanged="@(val => _fakt.Dimensions[dim.Name] = val)"
|
||||||
|
SearchFunc="@((text, token) => SearchPath(text, dim))"
|
||||||
|
ResetValueOnEmptyText="true"
|
||||||
|
CoerceText="true"
|
||||||
|
CoerceValue="true"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Placeholder="Tippen zum Suchen..."
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search" />
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudStack Row="true" Justify="Justify.FlexEnd">
|
||||||
|
<MudButton Variant="Variant.Text" Href="/">Abbrechen</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="SaveData">
|
||||||
|
Datensatz speichern
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? WorkspaceId { get; set; }
|
||||||
|
|
||||||
|
private List<Workspace> _workspaces = new();
|
||||||
|
private Workspace? _selectedWorkspace;
|
||||||
|
private AnalysisFakt _fakt = new();
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private DateTime? _selectedDate = DateTime.Today;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_workspaces = await DbService.GetWorkspacesForUserAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(WorkspaceId))
|
||||||
|
{
|
||||||
|
var preselected = _workspaces.FirstOrDefault(w => w.Id == WorkspaceId);
|
||||||
|
if (preselected != null)
|
||||||
|
{
|
||||||
|
OnWorkspaceSelected(preselected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWorkspaceSelected(Workspace ws)
|
||||||
|
{
|
||||||
|
_selectedWorkspace = ws;
|
||||||
|
_fakt = new AnalysisFakt { WorkspaceId = ws.Id ?? string.Empty };
|
||||||
|
_selectedDate = DateTime.Today;
|
||||||
|
|
||||||
|
// Wir bereiten das Dictionary für jede definierte Dimension vor
|
||||||
|
foreach (var dim in ws.Dimensions)
|
||||||
|
{
|
||||||
|
_fakt.Dimensions[dim.Name] = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die geniale Suchfunktion für das Autocomplete
|
||||||
|
private async Task<IEnumerable<string>> SearchPath(string value, DimensionDefinition dim)
|
||||||
|
{
|
||||||
|
var allPaths = new List<string>();
|
||||||
|
|
||||||
|
// Baum durchlaufen und Pfade sammeln
|
||||||
|
foreach (var node in dim.Nodes)
|
||||||
|
{
|
||||||
|
BuildPaths(node, "", allPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn der User noch nichts getippt hat, zeigen wir alle Pfade an
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return allPaths;
|
||||||
|
|
||||||
|
// Ansonsten filtern wir (Case-Insensitive)
|
||||||
|
return allPaths.Where(x => x.Contains(value, StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rekursive Methode, um aus dem Baum flache Pfade zu machen (z.B. "Europa/Italien")
|
||||||
|
private void BuildPaths(DimensionNode node, string currentPath, List<string> allPaths)
|
||||||
|
{
|
||||||
|
string path = string.IsNullOrEmpty(currentPath) ? node.Name : $"{currentPath} / {node.Name}";
|
||||||
|
|
||||||
|
// Wir fügen jeden Knoten als wählbare Option hinzu
|
||||||
|
allPaths.Add(path);
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
BuildPaths(child, path, allPaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveData()
|
||||||
|
{
|
||||||
|
if (_selectedDate.HasValue)
|
||||||
|
{
|
||||||
|
_fakt.Date = _selectedDate.Value.ToUniversalTime(); // Datenbanken lieben UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DbService.SaveFaktAsync(_fakt);
|
||||||
|
Snackbar.Add("Datensatz erfolgreich gespeichert!", Severity.Success);
|
||||||
|
|
||||||
|
// --- NEU: Komfort-Reset für die Massenerfassung ---
|
||||||
|
var gemerktesDatum = _selectedDate; // 1. Datum merken
|
||||||
|
|
||||||
|
_fakt = new AnalysisFakt { WorkspaceId = _selectedWorkspace!.Id ?? string.Empty };
|
||||||
|
_selectedDate = gemerktesDatum; // 2. Gemerktes Datum wieder einsetzen
|
||||||
|
|
||||||
|
// 3. Dimensionen wieder leeren, damit das Autocomplete frisch ist
|
||||||
|
foreach (var dim in _selectedWorkspace.Dimensions)
|
||||||
|
{
|
||||||
|
_fakt.Dimensions[dim.Name] = string.Empty;
|
||||||
|
}
|
||||||
|
// --------------------------------------------------
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Fehler beim Speichern: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-3
@@ -1,7 +1,135 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@using ZahlenAnalyse.Web.Models
|
||||||
|
@using ZahlenAnalyse.Web.Services
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@inject WorkspaceService DbService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-8">
|
||||||
|
<MudText Typo="Typo.h3">Meine Workspaces</MudText>
|
||||||
|
|
||||||
<h1>Hello, world!</h1>
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<MudButton Href="/workspaces/create"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
Neuer Workspace
|
||||||
|
</MudButton>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
Welcome to your new app.
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (!_workspaces.Any())
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-8 text-center" Elevation="1">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Large" Color="Color.Default" Class="mb-4" />
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Noch keine Workspaces vorhanden</MudText>
|
||||||
|
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6">
|
||||||
|
Erstelle deinen ersten Workspace (z.B. Urlaubsabrechnung), um mit der Datenanalyse zu beginnen.
|
||||||
|
</MudText>
|
||||||
|
<MudButton Href="/workspaces/create" Variant="Variant.Outlined" Color="Color.Primary">
|
||||||
|
Jetzt erstellen
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid>
|
||||||
|
@foreach (var ws in _workspaces)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudCard Elevation="2" Class="h-100">
|
||||||
|
<MudCardHeader>
|
||||||
|
<CardHeaderContent>
|
||||||
|
<MudText Typo="Typo.h6">@ws.Name</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
Erstellt am @ws.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy")
|
||||||
|
</MudText>
|
||||||
|
</CardHeaderContent>
|
||||||
|
<CardHeaderActions>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Settings"
|
||||||
|
Color="Color.Default"
|
||||||
|
Href="@($"/workspace/{ws.Id}")" />
|
||||||
|
</CardHeaderActions>
|
||||||
|
</MudCardHeader>
|
||||||
|
<MudCardContent>
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@ws.Dimensions.Count Dimensionen konfiguriert
|
||||||
|
</MudText>
|
||||||
|
<MudStack Row="true" Spacing="1" Class="mt-2 flex-wrap">
|
||||||
|
@foreach (var dim in ws.Dimensions.Take(3))
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@dim.Name</MudChip>
|
||||||
|
}
|
||||||
|
@if (ws.Dimensions.Count > 3)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Text">+@(ws.Dimensions.Count - 3)</MudChip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudCardContent>
|
||||||
|
<MudCardActions>
|
||||||
|
<MudButton Href="@($"/erfassen/{ws.Id}")" Variant="Variant.Text" Color="Color.Primary">
|
||||||
|
Daten erfassen
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Href="@($"/auswertung/{ws.Id}")" Variant="Variant.Text" Color="Color.Secondary">
|
||||||
|
Auswertung
|
||||||
|
</MudButton>
|
||||||
|
</MudCardActions>
|
||||||
|
</MudCard>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
}
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<MudPaper Class="pa-8 text-center" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-4">Willkommen beim Zahlen-Analyse Tool</MudText>
|
||||||
|
<MudText Typo="Typo.body1" Class="mb-6">Bitte melde dich an, um deine Daten zu verwalten.</MudText>
|
||||||
|
<MudButton Href="/login" Variant="Variant.Filled" Color="Color.Primary">
|
||||||
|
Mit Pocket-ID anmelden
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Workspace> _workspaces = new();
|
||||||
|
private bool _isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadWorkspaces();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadWorkspaces()
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Dank unseres Services reicht hier ein simpler Aufruf!
|
||||||
|
_workspaces = await DbService.GetWorkspacesForUserAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@using ZahlenAnalyse.Web.Models
|
||||||
|
|
||||||
|
<div class="pl-6 mt-2 border-l-2" style="border-color: var(--mud-palette-primary);">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudTextField @bind-Value="Node.Name"
|
||||||
|
Placeholder="z.B. Italien oder Maut"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Margin="Margin.Dense" />
|
||||||
|
|
||||||
|
<MudTooltip Text="Unterkategorie hinzufügen">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.SubdirectoryArrowRight"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Info"
|
||||||
|
OnClick="AddChild" />
|
||||||
|
</MudTooltip>
|
||||||
|
|
||||||
|
<MudTooltip Text="Knoten löschen">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Error"
|
||||||
|
OnClick="() => OnRemove.InvokeAsync(Node)" />
|
||||||
|
</MudTooltip>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@foreach (var child in Node.Children.ToList())
|
||||||
|
{
|
||||||
|
<NodeEditor Node="child" OnRemove="RemoveChild" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public DimensionNode Node { get; set; } = default!;
|
||||||
|
[Parameter] public EventCallback<DimensionNode> OnRemove { get; set; }
|
||||||
|
|
||||||
|
private void AddChild()
|
||||||
|
{
|
||||||
|
Node.Children.Add(new DimensionNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveChild(DimensionNode child)
|
||||||
|
{
|
||||||
|
Node.Children.Remove(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@page "/workspaces/create"
|
||||||
|
@using ZahlenAnalyse.Web.Models
|
||||||
|
@using ZahlenAnalyse.Web.Services
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@inject WorkspaceService DbService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavManager
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8 mb-8">
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-6">Neuen Workspace erstellen</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6 mb-6" Elevation="1">
|
||||||
|
<MudTextField @bind-Value="_workspace.Name"
|
||||||
|
Label="Name des Workspaces (z.B. Urlaubsabrechnung)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Required="true" />
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-4">Analysedimensionen</MudText>
|
||||||
|
|
||||||
|
@foreach (var dim in _workspace.Dimensions.ToList())
|
||||||
|
{
|
||||||
|
<MudCard Class="mb-4" Elevation="1">
|
||||||
|
<MudCardContent>
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4">
|
||||||
|
<MudTextField @bind-Value="dim.Name"
|
||||||
|
Label="Name der Dimension (z.B. Ort oder Kostenart)"
|
||||||
|
Variant="Variant.Outlined" />
|
||||||
|
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
|
Color="Color.Error"
|
||||||
|
OnClick="() => _workspace.Dimensions.Remove(dim)" />
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Hierarchie-Knoten:</MudText>
|
||||||
|
|
||||||
|
@foreach (var rootNode in dim.Nodes.ToList())
|
||||||
|
{
|
||||||
|
<NodeEditor Node="rootNode" OnRemove="(n) => dim.Nodes.Remove(n)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Color="Color.Success"
|
||||||
|
OnClick="() => dim.Nodes.Add(new DimensionNode())"
|
||||||
|
Class="mt-2">
|
||||||
|
Haupt-Knoten hinzufügen
|
||||||
|
</MudButton>
|
||||||
|
</MudCardContent>
|
||||||
|
</MudCard>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
StartIcon="@Icons.Material.Filled.AddBox"
|
||||||
|
Color="Color.Info"
|
||||||
|
OnClick="() => _workspace.Dimensions.Add(new DimensionDefinition())"
|
||||||
|
Class="mb-8">
|
||||||
|
Neue Dimension hinzufügen
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
|
<MudDivider Class="mb-4" />
|
||||||
|
|
||||||
|
<MudStack Row="true" Justify="Justify.FlexEnd">
|
||||||
|
<MudButton Variant="Variant.Text" Href="/">Abbrechen</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="SaveWorkspace">
|
||||||
|
Workspace speichern
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private Workspace _workspace = new();
|
||||||
|
|
||||||
|
private async Task SaveWorkspace()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_workspace.Name))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Bitte gib dem Workspace einen Namen.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Wir übergeben nur noch das blanke Formular-Objekt.
|
||||||
|
// Der Service kümmert sich um den Auth-Rest!
|
||||||
|
await DbService.SaveWorkspaceAsync(_workspace);
|
||||||
|
|
||||||
|
Snackbar.Add($"Workspace '{_workspace.Name}' erfolgreich gespeichert!", Severity.Success);
|
||||||
|
NavManager.NavigateTo("/");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Fehler beim Speichern: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@
|
|||||||
@using ZahlenAnalyse.Web
|
@using ZahlenAnalyse.Web
|
||||||
@using ZahlenAnalyse.Web.Components
|
@using ZahlenAnalyse.Web.Components
|
||||||
@using ZahlenAnalyse.Web.Components.Layout
|
@using ZahlenAnalyse.Web.Components.Layout
|
||||||
|
@using MudBlazor
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ZahlenAnalyse.Web.Models;
|
||||||
|
|
||||||
|
public record AnalysisFakt: IOwnedEntity, IAuditableEntity
|
||||||
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string WorkspaceId { get; set; } = string.Empty;
|
||||||
|
public string OwnerId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CreatedBy { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string> Dimensions { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ZahlenAnalyse.Web.Models;
|
||||||
|
|
||||||
|
public interface IOwnedEntity
|
||||||
|
{
|
||||||
|
string OwnerId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAuditableEntity
|
||||||
|
{
|
||||||
|
string CreatedBy { get; set; }
|
||||||
|
DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ZahlenAnalyse.Web.Models;
|
||||||
|
|
||||||
|
public record Workspace: IOwnedEntity, IAuditableEntity
|
||||||
|
{
|
||||||
|
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string OwnerId { get; set; } = string.Empty; // Deine Pocket-ID (Sub)
|
||||||
|
|
||||||
|
public string CreatedBy { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public List<DimensionDefinition> Dimensions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DimensionDefinition
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<DimensionNode> Nodes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DimensionNode
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<DimensionNode> Children { get; set; } = new();
|
||||||
|
}
|
||||||
+74
@@ -1,10 +1,62 @@
|
|||||||
using ZahlenAnalyse.Web.Components;
|
using ZahlenAnalyse.Web.Components;
|
||||||
|
using MudBlazor.Services;
|
||||||
|
using Raven.Client.Documents;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using DotNetEnv;
|
||||||
|
using ZahlenAnalyse.Web.Services;
|
||||||
|
|
||||||
|
Env.Load();
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
builder.Services.AddMudServices();
|
||||||
|
|
||||||
|
var store = new DocumentStore
|
||||||
|
{
|
||||||
|
Urls = new[] { builder.Configuration["RavenDb:Urls"] },
|
||||||
|
Database = builder.Configuration["RavenDb:Database"]
|
||||||
|
};
|
||||||
|
store.Initialize();
|
||||||
|
builder.Services.AddSingleton<IDocumentStore>(store);
|
||||||
|
builder.Services.AddScoped<WorkspaceService>();
|
||||||
|
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
|
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
var pocketIdConfig = builder.Configuration.GetSection("PocketId");
|
||||||
|
|
||||||
|
options.Authority = pocketIdConfig["Authority"];
|
||||||
|
options.ClientId = pocketIdConfig["ClientId"];
|
||||||
|
options.ClientSecret = pocketIdConfig["ClientSecret"];
|
||||||
|
|
||||||
|
options.ResponseType = "code";
|
||||||
|
options.SaveTokens = true;
|
||||||
|
|
||||||
|
// Wichtig für lokale Dev-Umgebungen ohne HTTPS-Zertifikatsprüfung (falls nötig)
|
||||||
|
// options.RequireHttpsMetadata = false;
|
||||||
|
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
NameClaimType = "name",
|
||||||
|
RoleClaimType = "roles"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -21,7 +73,29 @@ app.UseHttpsRedirection();
|
|||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
|
||||||
|
app.MapGet("/login", async (HttpContext context) =>
|
||||||
|
{
|
||||||
|
await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = "/"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/logout", async (HttpContext context) =>
|
||||||
|
{
|
||||||
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = "/"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Raven.Client.Documents;
|
||||||
|
using ZahlenAnalyse.Web.Models;
|
||||||
|
using ExcelDataReader;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace ZahlenAnalyse.Web.Services;
|
||||||
|
|
||||||
|
public class WorkspaceService
|
||||||
|
{
|
||||||
|
private readonly IDocumentStore _store;
|
||||||
|
private readonly AuthenticationStateProvider _authStateProvider;
|
||||||
|
|
||||||
|
// Den AuthStateProvider injizieren
|
||||||
|
public WorkspaceService(IDocumentStore store, AuthenticationStateProvider authStateProvider)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_authStateProvider = authStateProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetUserIdAsync()
|
||||||
|
{
|
||||||
|
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var user = authState.User;
|
||||||
|
var userid = user.FindFirst(c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value
|
||||||
|
?? user.FindFirst("sub")?.Value
|
||||||
|
?? string.Empty;
|
||||||
|
return userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Die Magie passiert hier ---
|
||||||
|
private async Task EnrichWithAuditDataAsync(object entity)
|
||||||
|
{
|
||||||
|
// Wenn das Objekt weder IOwnedEntity noch IAuditableEntity ist, können wir abbrechen
|
||||||
|
if (entity is not IOwnedEntity and not IAuditableEntity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var user = authState.User;
|
||||||
|
|
||||||
|
if (entity is IOwnedEntity ownedEntity)
|
||||||
|
{
|
||||||
|
var userid = await GetUserIdAsync();
|
||||||
|
|
||||||
|
// Setzt bei JEDEM Speichern sicherheitshalber den aktuellen User als Owner
|
||||||
|
ownedEntity.OwnerId = userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is IAuditableEntity auditableEntity)
|
||||||
|
{
|
||||||
|
// WICHTIG: Wir setzen CreatedBy und CreatedAt NUR, wenn sie noch leer sind.
|
||||||
|
// Sonst würden wir bei einem Update (z.B. Namensänderung des Workspaces)
|
||||||
|
// das ursprüngliche Erstellungsdatum und den ursprünglichen Ersteller überschreiben!
|
||||||
|
if (string.IsNullOrWhiteSpace(auditableEntity.CreatedBy))
|
||||||
|
{
|
||||||
|
auditableEntity.CreatedBy = user.FindFirst("name")?.Value ?? user.Identity?.Name ?? "Unbekannt";
|
||||||
|
auditableEntity.CreatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveWorkspaceAsync(Workspace workspace)
|
||||||
|
{
|
||||||
|
// 1. Audit-Daten automatisch befüllen
|
||||||
|
await EnrichWithAuditDataAsync(workspace);
|
||||||
|
|
||||||
|
// 2. Speichern
|
||||||
|
using var session = _store.OpenAsyncSession();
|
||||||
|
await session.StoreAsync(workspace);
|
||||||
|
await session.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Workspace>> GetWorkspacesForUserAsync()
|
||||||
|
{
|
||||||
|
|
||||||
|
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var ownerId = await GetUserIdAsync();
|
||||||
|
|
||||||
|
using var session = _store.OpenAsyncSession();
|
||||||
|
return await session.Query<Workspace>()
|
||||||
|
.Where(w => w.OwnerId == ownerId)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Workspace?> GetWorkspaceAsync(string id)
|
||||||
|
{
|
||||||
|
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var currentUserId = await GetUserIdAsync();
|
||||||
|
|
||||||
|
using var session = _store.OpenAsyncSession();
|
||||||
|
var workspace = await session.LoadAsync<Workspace>(id);
|
||||||
|
|
||||||
|
|
||||||
|
if (workspace != null && workspace.OwnerId != currentUserId)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveFaktAsync(AnalysisFakt fakt)
|
||||||
|
{
|
||||||
|
// Unsere elegante Hilfsmethode von vorhin füllt OwnerId, CreatedBy und CreatedAt!
|
||||||
|
await EnrichWithAuditDataAsync(fakt);
|
||||||
|
|
||||||
|
using var session = _store.OpenAsyncSession();
|
||||||
|
await session.StoreAsync(fakt);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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="MudBlazor" Version="9.5.0" />
|
||||||
<PackageReference Include="RavenDB.Client" Version="7.2.2" />
|
<PackageReference Include="RavenDB.Client" Version="7.2.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,28 +1,3 @@
|
|||||||
html, body {
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
a, .btn-link {
|
|
||||||
color: #006bb7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #1b6ec2;
|
|
||||||
border-color: #1861ac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
|
||||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding-top: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valid.modified:not([type=checkbox]) {
|
.valid.modified:not([type=checkbox]) {
|
||||||
outline: 1px solid #26b050;
|
outline: 1px solid #26b050;
|
||||||
@@ -45,16 +20,3 @@ h1:focus {
|
|||||||
.blazor-error-boundary::after {
|
.blazor-error-boundary::after {
|
||||||
content: "An error has occurred."
|
content: "An error has occurred."
|
||||||
}
|
}
|
||||||
|
|
||||||
.darker-border-checkbox.form-check-input {
|
|
||||||
border-color: #929292;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
|
||||||
color: var(--bs-secondary-color);
|
|
||||||
text-align: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user