Files

398 lines
17 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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;
}
}
}