Import: CSV mapping wizard with revertible batches

New /import/wizard: upload an arbitrary CSV, preview the column grid, map each column to a role + target meter/category + unit, dry-run, then commit as a revertible import_batch. The /import page now lists recent imports with one-click revert. Adds CsvImporter.ReadRawRows (raw preview) and Confirm.ConfirmAsync (generic confirm). Verified end-to-end in a real browser + direct DB checks.

Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
This commit is contained in:
2026-07-17 11:05:01 +02:00
parent b3b8b92520
commit 14930bc3d8
4 changed files with 507 additions and 6 deletions
+100 -6
View File
@@ -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<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject IDialogService Dialogs
@inject ISnackbar Snackbar
@using MeterVault.Infrastructure.Import
@using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Import</PageTitle>
@@ -29,18 +34,23 @@
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6">Dry-run a CSV</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Upload a sheet and preview what would be staged (no changes are made).
<div class="d-flex align-center justify-space-between">
<MudText Typo="Typo.h6">Your own CSV</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
</div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
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.
</MudText>
<MudSelect T="string" @bind-Value="_profileName" Label="Sheet type" Dense="true" Class="mb-2">
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
</MudSelect>
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
Choose CSV
Dry-run a reference sheet
</MudButton>
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
</MudPaper>
@@ -71,6 +81,49 @@
</MudPaper>
</MudItem>
}
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
@if (_batches.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
<tbody>
@foreach (var batch in _batches)
{
<tr>
<td>@batch.Id</td>
<td>@(batch.SourceName ?? "—")</td>
<td style="text-align:right">@batch.RowCount</td>
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
<td>
@if (batch.RevertedAt is not null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
}
</td>
<td style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
StartIcon="@Icons.Material.Filled.Undo"
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
@@ -78,9 +131,49 @@
private bool _referenceLoaded;
private string _profileName = "Strom";
private StagedImport? _preview;
private List<ImportBatch> _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)
{