diff --git a/src/App/Components/Pages/Import.razor b/src/App/Components/Pages/Import.razor index 9c30ab6..6647e6b 100644 --- a/src/App/Components/Pages/Import.razor +++ b/src/App/Components/Pages/Import.razor @@ -1,8 +1,13 @@ @page "/import" @inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter @inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter +@inject MeterVault.Infrastructure.Import.ImportService ImportService +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject IDialogService Dialogs @inject ISnackbar Snackbar @using MeterVault.Infrastructure.Import +@using MeterVault.Infrastructure.Persistence +@using Microsoft.EntityFrameworkCore MeterVault — Import @@ -29,18 +34,23 @@ - Dry-run a CSV - - Upload a sheet and preview what would be staged (no changes are made). +
+ Your own CSV + Mapping wizard +
+ + Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a + revertible import. Or dry-run against one of the built-in reference profiles below. - + Electricity (Strom) Water (Wasser) Heating oil (Heizöl) Costs (Kosten) - Choose CSV + Dry-run a reference sheet
@@ -71,6 +81,49 @@
} + + + + Recent imports + @if (_batches.Count == 0) + { + No imports yet. + } + else + { + + #SourceRowsImportedStatus + + @foreach (var batch in _batches) + { + + @batch.Id + @(batch.SourceName ?? "—") + @batch.RowCount + @batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm") + + @if (batch.RevertedAt is not null) + { + reverted + } + else + { + active + } + + + Revert + + + } + + + } + + @code { @@ -78,9 +131,49 @@ private bool _referenceLoaded; private string _profileName = "Strom"; private StagedImport? _preview; + private List _batches = []; + private int? _reverting; - protected override async Task OnInitializedAsync() => + protected override async Task OnInitializedAsync() + { _referenceLoaded = await ReferenceImporter.IsLoadedAsync(); + await LoadBatchesAsync(); + } + + private async Task LoadBatchesAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _batches = await db.ImportBatches.AsNoTracking() + .OrderByDescending(b => b.Id) + .Take(25) + .ToListAsync(); + } + + private async Task RevertAsync(ImportBatch batch) + { + if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?", + $"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?", + "Revert")) + { + return; + } + + _reverting = batch.Id; + try + { + await ImportService.RevertAsync(batch.Id); + Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success); + await LoadBatchesAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error); + } + finally + { + _reverting = null; + } + } private async Task LoadReferenceAsync() { @@ -91,6 +184,7 @@ await ReferenceImporter.LoadAsync(dir); _referenceLoaded = true; Snackbar.Add("Reference data loaded.", Severity.Success); + await LoadBatchesAsync(); } catch (Exception ex) { diff --git a/src/App/Components/Pages/ImportWizard.razor b/src/App/Components/Pages/ImportWizard.razor new file mode 100644 index 0000000..5879bff --- /dev/null +++ b/src/App/Components/Pages/ImportWizard.razor @@ -0,0 +1,393 @@ +@page "/import/wizard" +@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter +@inject MeterVault.Infrastructure.Import.ImportService ImportService +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject NavigationManager Nav +@using System.Text.Json +@using MeterVault.Core.Domain +@using MeterVault.Infrastructure.Import +@using MeterVault.Infrastructure.Persistence +@using Microsoft.EntityFrameworkCore + +MeterVault — Import wizard + +
+ + Import wizard +
+ + + Upload any CSV, map its columns to your meters and categories, preview what would be staged, then + commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes, + Monat JJJJ or TT.MM.JJJJ dates) — the same parser the reference sheets use. + + + +
+ + Choose CSV + +
+
+ +@if (_colCount > 0) +{ + + 1. Parsing options + + + + @foreach (var i in Enumerable.Range(0, _colCount)) + { + @ColLabel(i) + } + + + + + Auto-detect + Month name (Januar 2024) + Day (31.12.2024) + + + + + + + + + + + + + + + + + 2. Column preview +
+ + + + @foreach (var i in Enumerable.Range(0, _colCount)) + { + + Col @i@(i == _dateColumn ? " 📅" : "") + + } + + + + @foreach (var r in Enumerable.Range(0, Math.Min(_rows.Count, _firstDataRow + 8))) + { + + @foreach (var i in Enumerable.Range(0, _colCount)) + { + @Cell(r, i) + } + + } + + +
+ + Faded rows are before the first data row. The 📅 column supplies the date. + +
+ + + 3. Map columns +
+ + + ColumnSampleRoleTargetUnit + + + @foreach (var i in Enumerable.Range(0, _colCount)) + { + + + Col @i + @if (!string.IsNullOrWhiteSpace(Header(i))) + { +
@Header(i) + } + + @Sample(i) + + + @foreach (var role in Enum.GetValues()) + { + @role + } + + + + @if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) + { + + @foreach (var m in _meters) + { + @m.Name (@m.Unit) + } + + } + else if (_columns[i].Role == MappingRole.ManualCost) + { + + @foreach (var c in _categories) + { + @c.Name + } + + } + + + @if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) + { + + } + + + } + +
+
+
+ + @if (_validationErrors.Count > 0) + { + + @foreach (var err in _validationErrors) + { +
@err
+ } +
+ } + + +
+ 4. Preview & commit + Dry-run preview + + Commit import + + @if (_committing) + { + + } +
+ + @if (_staged is not null) + { +
+ Readings: @_staged.Readings.Count + Events: @_staged.Events.Count + Manual costs: @_staged.ManualCosts.Count + Skipped rows: @_staged.SkippedRows +
+ @if (_staged.TotalRows == 0) + { + + Nothing staged. Check the first-data-row, date column and column mappings above. + + } + @if (_staged.Warnings.Count > 0) + { + + + @foreach (var warning in _staged.Warnings.Take(100)) + { + @warning + } + + + } + } +
+} + +@code { + private sealed class ColumnState + { + public MappingRole Role { get; set; } = MappingRole.Ignore; + public int? MeterId { get; set; } + public int? CategoryId { get; set; } + public string? Unit { get; set; } + } + + private string? _fileName; + private string? _csvText; + private List _rows = []; + private int _colCount; + private ColumnState[] _columns = []; + + private int _dateColumn; + private DateKind _dateKind = DateKind.Auto; + private int _headerRow; + private int _firstDataRow = 1; + private bool _skipAllZero = true; + private bool _detectSwaps; + + private List _meters = []; + private List _categories = []; + private StagedImport? _staged; + private List _validationErrors = []; + private bool _committing; + + private async Task OnFileAsync(InputFileChangeEventArgs args) + { + var file = args.File; + if (file is null) + { + return; + } + + _fileName = file.Name; + using (var reader = new StreamReader(file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024))) + { + _csvText = await reader.ReadToEndAsync(); + } + + _rows = CsvImporter.ReadRawRows(new StringReader(_csvText)).ToList(); + _colCount = _rows.Count == 0 ? 0 : _rows.Max(r => r.Length); + _columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray(); + _dateColumn = 0; + _staged = null; + _validationErrors = []; + + await using var db = await DbFactory.CreateDbContextAsync(); + _meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync(); + _categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync(); + } + + private void Preview() + { + _validationErrors = Validate(); + if (_validationErrors.Count > 0) + { + _staged = null; + return; + } + + using var reader = new StringReader(_csvText ?? string.Empty); + _staged = CsvImporter.Stage(BuildProfile(), reader); + } + + private async Task CommitAsync() + { + if (_staged is null || _staged.TotalRows == 0) + { + return; + } + + _committing = true; + try + { + var mappingJson = JsonSerializer.Serialize(new + { + dateColumn = _dateColumn, + dateKind = _dateKind.ToString(), + firstDataRow = _firstDataRow, + columns = _columns + .Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit }) + .Where(c => c.role != nameof(MappingRole.Ignore)), + }); + + var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson); + Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success); + Nav.NavigateTo("/import"); + } + catch (Exception ex) + { + Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error); + } + finally + { + _committing = false; + } + } + + private List Validate() + { + var errors = new List(); + if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0) + { + errors.Add("Map at least one column to a role other than Ignore."); + } + + for (var i = 0; i < _columns.Length; i++) + { + var c = _columns[i]; + if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null) + { + errors.Add($"Col {i} ({c.Role}) needs a target meter."); + } + + if (c.Role == MappingRole.ManualCost && c.CategoryId is null) + { + errors.Add($"Col {i} (ManualCost) needs a target category."); + } + } + + return errors; + } + + private MappingProfile BuildProfile() => new() + { + Name = _fileName ?? "Custom CSV", + HeaderRowIndex = _headerRow, + FirstDataRowIndex = _firstDataRow, + DateColumn = _dateColumn, + DateKind = _dateKind, + SkipAllZeroRows = _skipAllZero, + DetectCumulativeSwaps = _detectSwaps, + Columns = Enumerable.Range(0, _colCount) + .Where(i => _columns[i].Role != MappingRole.Ignore) + .Select(i => new ColumnMapping + { + Index = i, + Role = _columns[i].Role, + MeterId = _columns[i].MeterId, + CategoryId = _columns[i].CategoryId, + Unit = string.IsNullOrWhiteSpace(_columns[i].Unit) ? null : _columns[i].Unit, + }) + .ToList(), + }; + + private string Cell(int row, int col) => + row >= 0 && row < _rows.Count && col < _rows[row].Length ? _rows[row][col] : string.Empty; + + private string Header(int col) => Cell(_headerRow, col); + + private string Sample(int col) + { + for (var r = _firstDataRow; r < _rows.Count && r < _firstDataRow + 20; r++) + { + var value = Cell(r, col); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return string.Empty; + } + + private string ColLabel(int col) + { + var header = Header(col); + return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}"; + } +} diff --git a/src/App/Confirm.cs b/src/App/Confirm.cs index 5d5ddcf..3d5fcd2 100644 --- a/src/App/Confirm.cs +++ b/src/App/Confirm.cs @@ -11,4 +11,12 @@ public static class Confirm .ConfigureAwait(false); return result == true; } + + /// A generic yes/cancel confirmation with a caller-supplied confirm-button label. + public static async Task ConfirmAsync(IDialogService dialog, string title, string message, string confirmText) + { + var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: "Cancel") + .ConfigureAwait(false); + return result == true; + } } diff --git a/src/Infrastructure/Import/CsvImporter.cs b/src/Infrastructure/Import/CsvImporter.cs index 9be28f0..12a0504 100644 --- a/src/Infrastructure/Import/CsvImporter.cs +++ b/src/Infrastructure/Import/CsvImporter.cs @@ -222,6 +222,12 @@ public sealed class CsvImporter return true; } + /// + /// Reads the raw CSV grid (no profile applied) so the import wizard can show a column preview + /// and let the user map columns before staging. Rows are ragged (their own field counts). + /// + public static IReadOnlyList ReadRawRows(TextReader reader) => ReadRows(reader); + internal static List ReadRows(TextReader reader) { var config = new CsvConfiguration(CultureInfo.InvariantCulture)