TireInspectionPanel: aufklappbare, editierbare Profiltiefen-/QR-Report-Ansicht in allen Reifenstapel-Ansichten

This commit is contained in:
spaethf committed 2026-09-26 23:48:10 +02:00
1 parent 766703524a
commit 7a0406eaa2
9 files changed
+825 -58

No files matched your search

@@ -0,0 +1,397 @@
@inject ITireStackService TireStackService
@inject ITireReportService TireReportService
@inject UserSessionService UserSession
<section class="insp-panel @(Compact ? "compact" : "")" aria-label="Profiltiefen-Messungen">
<button type="button" class="insp-toggle" @onclick="Toggle" aria-expanded="@isOpen" aria-controls="@panelId">
<span class="insp-toggle-icon" aria-hidden="true">📋</span>
<span class="insp-toggle-title">Profiltiefen / Reifen-Report</span>
@if (latest != null)
{
<span class="insp-toggle-summary">
<span class="insp-chip rating-@RatingClass(latest.RatingFL)">VL @Fmt(latest.TreadDepthFL)</span>
<span class="insp-chip rating-@RatingClass(latest.RatingFR)">VR @Fmt(latest.TreadDepthFR)</span>
<span class="insp-chip rating-@RatingClass(latest.RatingRL)">HL @Fmt(latest.TreadDepthRL)</span>
<span class="insp-chip rating-@RatingClass(latest.RatingRR)">HR @Fmt(latest.TreadDepthRR)</span>
</span>
}
else if (loaded)
{
<span class="insp-toggle-summary muted">Keine Messung</span>
}
<span class="insp-chevron @(isOpen ? "open" : "")" aria-hidden="true">▾</span>
</button>
@if (isOpen)
{
<div id="@panelId" class="insp-body">
@if (isLoading)
{
<div class="insp-status"><span class="spinner"></span> Messungen werden geladen…</div>
}
else
{
@if (!string.IsNullOrEmpty(error))
{
<div class="insp-error" role="alert">⚠️ @error</div>
}
@if (AllowEdit)
{
<div class="insp-actions">
<button type="button" class="btn btn-sm btn-outline" @onclick="OpenScanner" disabled="@(isBusy || editing != null)">
@if (isFetching) { <span class="spinner"></span><span>Report wird geladen…</span> }
else { <span>📷 QR-Report scannen</span> }
</button>
<button type="button" class="btn btn-sm btn-outline" @onclick="StartManual" disabled="@(isBusy || editing != null)">
✎ Manuell erfassen
</button>
</div>
}
@if (editing != null)
{
<div class="insp-edit">
<h4>@(isNew ? "Neue Messung" : "Messung bearbeiten")</h4>
<div class="insp-meta">
<div class="form-group">
<label>Datum</label>
<input type="date" class="form-control" value="@(editing.MeasuredAt?.ToLocalTime().ToString("yyyy-MM-dd"))" @onchange="OnDateChanged" />
</div>
<div class="form-group">
<label>Kennzeichen</label>
<input type="text" class="form-control" @bind="editing.LicensePlate" placeholder="z.B. ND-XX 123" maxlength="20" />
</div>
<div class="form-group">
<label>Kilometerstand</label>
<input type="number" class="form-control" @bind="editing.Mileage" min="0" placeholder="km" />
</div>
</div>
<div class="tread-grid" role="group" aria-label="Profiltiefen">
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthFL))">
<label>Vorne links</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthFL" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthFR))">
<label>Vorne rechts</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthFR" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthRL))">
<label>Hinten links</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthRL" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthRR))">
<label>Hinten rechts</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthRR" /><span>mm</span></div>
</div>
</div>
<div class="form-group">
<label>Empfehlung / Bemerkung</label>
<textarea class="form-control" rows="2" @bind="editing.Recommendation" maxlength="1000"></textarea>
</div>
@if (!string.IsNullOrEmpty(editing.ReportUrl))
{
<a class="insp-link" href="@editing.ReportUrl" target="_blank" rel="noopener">Original-Report öffnen ↗</a>
}
<div class="insp-edit-footer">
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelEdit" disabled="@isBusy">Abbrechen</button>
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveEdit" disabled="@isBusy">
@if (isBusy) { <span class="spinner"></span> }
<span>Speichern</span>
</button>
</div>
</div>
}
@if (inspections.Count == 0)
{
@if (editing == null)
{
<p class="insp-empty">Noch keine Profiltiefen-Messung für diesen Satz vorhanden.</p>
}
}
else
{
<ul class="insp-list" role="list">
@foreach (var insp in inspections)
{
<li @key="insp.Id" class="insp-item @(insp == latest ? "latest" : "")">
<div class="insp-item-head">
<span class="insp-date">
@((insp.MeasuredAt ?? insp.CreatedAt).ToLocalTime().ToString("dd.MM.yyyy HH:mm"))
@if (insp == latest) { <span class="insp-badge">aktuell</span> }
</span>
<span class="insp-source muted">@insp.Source@(string.IsNullOrEmpty(insp.RecordSn) ? "" : $" · {insp.RecordSn}")</span>
@if (AllowEdit)
{
<span class="insp-item-actions">
<button type="button" class="btn-link" @onclick="() => StartEdit(insp)" disabled="@(isBusy || editing != null)">Bearbeiten</button>
@if (confirmDeleteId == insp.Id)
{
<button type="button" class="btn-link danger" @onclick="() => Delete(insp)" disabled="@isBusy">Wirklich löschen</button>
<button type="button" class="btn-link" @onclick="() => confirmDeleteId = null" disabled="@isBusy">Nein</button>
}
else
{
<button type="button" class="btn-link danger" @onclick="() => confirmDeleteId = insp.Id" disabled="@(isBusy || editing != null)">Löschen</button>
}
</span>
}
</div>
<div class="insp-chips">
<span class="insp-chip rating-@RatingClass(insp.RatingFL)">VL @Fmt(insp.TreadDepthFL)</span>
<span class="insp-chip rating-@RatingClass(insp.RatingFR)">VR @Fmt(insp.TreadDepthFR)</span>
<span class="insp-chip rating-@RatingClass(insp.RatingRL)">HL @Fmt(insp.TreadDepthRL)</span>
<span class="insp-chip rating-@RatingClass(insp.RatingRR)">HR @Fmt(insp.TreadDepthRR)</span>
</div>
<div class="insp-details muted">
@if (!string.IsNullOrEmpty(insp.LicensePlate)) { <span>🚗 @insp.LicensePlate</span> }
@if (insp.Mileage.HasValue) { <span>@insp.Mileage.Value.ToString("N0", De) km</span> }
@if (!string.IsNullOrEmpty(insp.ReportUrl)) { <a class="insp-link" href="@insp.ReportUrl" target="_blank" rel="noopener">Report ↗</a> }
</div>
@if (!string.IsNullOrEmpty(insp.Recommendation))
{
<div class="insp-recommendation">💡 @insp.Recommendation</div>
}
</li>
}
</ul>
}
}
</div>
}
</section>
@if (AllowEdit)
{
<QrScannerModal @bind-IsOpen="showScanner"
Title="Reifen-Report scannen"
Subtitle="@ScannerSubtitle"
OnScanned="OnQrScanned" />
}
@code {
[Parameter, EditorRequired] public Guid TireStackId { get; set; }
[Parameter] public bool AllowEdit { get; set; }
[Parameter] public bool Compact { get; set; }
[Parameter] public bool InitiallyOpen { get; set; }
[Parameter] public string? ScannerSubtitle { get; set; }
[Parameter] public EventCallback<TireInspection?> OnLatestChanged { get; set; }
private static readonly System.Globalization.CultureInfo De = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
private readonly string panelId = $"insp-{Guid.NewGuid():N}";
private Guid loadedFor;
private bool isOpen;
private bool loaded;
private bool isLoading;
private bool isBusy;
private bool isFetching;
private bool showScanner;
private string? error;
private List<TireInspection> inspections = new();
private TireInspection? latest;
private TireInspection? editing;
private bool isNew;
private Guid? confirmDeleteId;
protected override async Task OnParametersSetAsync()
{
if (loadedFor != TireStackId)
{
loadedFor = TireStackId;
inspections = new();
latest = null;
loaded = false;
editing = null;
confirmDeleteId = null;
error = null;
isOpen = InitiallyOpen;
await LoadAsync();
}
}
private async Task LoadAsync()
{
isLoading = true;
try
{
inspections = await TireStackService.GetInspectionsAsync(TireStackId);
latest = inspections.FirstOrDefault();
loaded = true;
}
catch (Exception ex)
{
error = $"Messungen konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
}
}
private void Toggle() => isOpen = !isOpen;
private static string RatingClass(TireMeasureRating r) => r switch
{
TireMeasureRating.Ok => "ok",
TireMeasureRating.Warning => "warn",
TireMeasureRating.Critical => "crit",
_ => "none"
};
private static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", De) + " mm" : "–";
private void OpenScanner()
{
error = null;
showScanner = true;
}
private async Task OnQrScanned(string text)
{
showScanner = false;
if (!TireReportService.TryParseReportUrl(text, out var recordSn, out _))
{
error = "Der QR-Code enthält keinen gültigen Reifen-Report-Link.";
return;
}
isFetching = true;
error = null;
StateHasChanged();
try
{
var report = await TireReportService.FetchAsync(recordSn);
report.RecomputeRatings();
editing = report.ToInspection(TireStackId, UserSession.CurrentUser?.Id);
isNew = true;
}
catch (TireReportException ex)
{
error = ex.Message;
}
catch (Exception ex)
{
error = $"Report konnte nicht verarbeitet werden: {ex.Message}";
}
finally
{
isFetching = false;
}
}
private void StartManual()
{
error = null;
confirmDeleteId = null;
isNew = true;
editing = new TireInspection
{
TireStackId = TireStackId,
Source = "Manuell",
MeasuredAt = DateTime.UtcNow,
CreatedByUserId = UserSession.CurrentUser?.Id
};
}
private void StartEdit(TireInspection insp)
{
error = null;
confirmDeleteId = null;
isNew = false;
editing = new TireInspection
{
Id = insp.Id,
TireStackId = insp.TireStackId,
Source = insp.Source,
RecordSn = insp.RecordSn,
ReportUrl = insp.ReportUrl,
MeasuredAt = insp.MeasuredAt,
LicensePlate = insp.LicensePlate,
Mileage = insp.Mileage,
TreadDepthFL = insp.TreadDepthFL,
TreadDepthFR = insp.TreadDepthFR,
TreadDepthRL = insp.TreadDepthRL,
TreadDepthRR = insp.TreadDepthRR,
Recommendation = insp.Recommendation,
RawJson = insp.RawJson,
CreatedAt = insp.CreatedAt,
CreatedByUserId = insp.CreatedByUserId
};
}
private void OnDateChanged(ChangeEventArgs e)
{
if (editing == null) return;
if (DateTime.TryParse(e.Value?.ToString(), out var d))
editing.MeasuredAt = DateTime.SpecifyKind(d, DateTimeKind.Local).ToUniversalTime();
else
editing.MeasuredAt = null;
}
private void CancelEdit()
{
editing = null;
error = null;
}
private async Task SaveEdit()
{
if (editing == null) return;
isBusy = true;
error = null;
StateHasChanged();
try
{
editing.RatingFL = TireInspection.RateDepth(editing.TreadDepthFL);
editing.RatingFR = TireInspection.RateDepth(editing.TreadDepthFR);
editing.RatingRL = TireInspection.RateDepth(editing.TreadDepthRL);
editing.RatingRR = TireInspection.RateDepth(editing.TreadDepthRR);
editing.LicensePlate = string.IsNullOrWhiteSpace(editing.LicensePlate) ? null : editing.LicensePlate.Trim();
editing.Recommendation = string.IsNullOrWhiteSpace(editing.Recommendation) ? null : editing.Recommendation.Trim();
if (isNew)
await TireStackService.AddInspectionAsync(editing);
else
await TireStackService.UpdateInspectionAsync(editing);
editing = null;
await LoadAsync();
await OnLatestChanged.InvokeAsync(latest);
}
catch (Exception ex)
{
error = $"Speichern fehlgeschlagen: {ex.Message}";
}
finally
{
isBusy = false;
}
}
private async Task Delete(TireInspection insp)
{
isBusy = true;
error = null;
try
{
await TireStackService.DeleteInspectionAsync(insp.Id);
confirmDeleteId = null;
await LoadAsync();
await OnLatestChanged.InvokeAsync(latest);
}
catch (Exception ex)
{
error = $"Löschen fehlgeschlagen: {ex.Message}";
}
finally
{
isBusy = false;
}
}
}