Reifenwechsel: QR-Report-Integration fuer eingelagerten Satz

This commit is contained in:
spaethf committed 2026-09-26 23:33:43 +02:00
1 parent 3404fb9875
commit 766703524a
2 files changed
+540 -1

No files matched your search

@@ -4,6 +4,7 @@
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject IStorageLocationService StorageLocationService
@inject ITireReportService TireReportService
@inject UserSessionService UserSession
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
@@ -210,6 +211,39 @@ else
<div class="swap-box incoming">
<h3 class="swap-box-title"><span aria-hidden="true">⬇️</span> Einlagerung (kommt vom Fahrzeug)</h3>
@* QR report scan for the incoming set *@
<div class="report-scan-bar">
@if (swapReport == null)
{
<div class="report-scan-text">
<span class="report-scan-icon" aria-hidden="true">📷</span>
<span>Profiltiefen-Report (Launch TTS01) per QR-Code übernehmen</span>
</div>
<button type="button" class="btn btn-sm btn-outline" @onclick="OpenQrScanner" disabled="@(isLoadingReport || isProcessing)">
@if (isLoadingReport)
{
<span class="spinner"></span>
<span>Report wird geladen…</span>
}
else
{
<span>QR-Code scannen</span>
}
</button>
}
else
{
<div class="report-attached">
<div class="report-attached-head">
<span class="report-scan-icon" aria-hidden="true">📋</span>
<strong>Report wird mit der Einlagerung gespeichert</strong>
<button type="button" class="btn-link" @onclick="RemoveSwapReport">Entfernen</button>
</div>
@ReportSummaryFragment(swapReport)
</div>
}
</div>
<div class="swap-tabs" role="tablist">
<button type="button" class="swap-tab @(swapUseHistory ? "active" : "")" role="tab" aria-selected="@swapUseHistory"
@onclick="() => swapUseHistory = true" disabled="@(swapHistory.Count == 0)">
@@ -402,6 +436,91 @@ else
HighlightLocationId="@swapOutgoing.StorageLocationId"
Subtitle="@($"Einlagerung für {swapOutgoing.Customer?.FullName} – {SeasonLabel(swapUseHistory ? (swapSelectedHistory?.Season ?? swapNew.Season) : swapNew.Season)}")"
OnSelected="OnTargetLocationSelected" />
<QrScannerModal @bind-IsOpen="showQrScanner"
Title="Reifen-Report scannen"
Subtitle="@(swapOutgoing.Customer != null ? $"Kunde: {swapOutgoing.Customer.FullName}" : null)"
OnScanned="OnQrScanned" />
}
@* Report preview modal (swap) *@
@if (scannedReport != null)
{
<div class="report-overlay" @onclick="CloseReportModal">
<div class="report-dialog" role="dialog" aria-modal="true" aria-labelledby="report-title" @onclick:stopPropagation="true">
<div class="report-header">
<div>
<h3 id="report-title">📋 Reifen-Report</h3>
<p class="report-subtitle">
Nr. <span class="mono">@scannedReport.RecordSn</span>
@if (scannedReport.MeasuredAt.HasValue)
{
<text> · gemessen am @scannedReport.MeasuredAt.Value.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</text>
}
</p>
</div>
<button type="button" class="report-close" @onclick="CloseReportModal" aria-label="Schließen">✕</button>
</div>
<div class="report-body">
@if (!scannedReport.HasTreadData)
{
<div class="report-warning">
⚠️ Der Report enthält keine Profiltiefen-Werte. Werte können unten manuell ergänzt werden.
</div>
}
<div class="report-meta">
<div class="form-group">
<label>Kennzeichen</label>
<input type="text" class="form-control" @bind="scannedReport.LicensePlate" placeholder="z.B. ND-XX 123" />
</div>
<div class="form-group">
<label>Kilometerstand</label>
<input type="number" class="form-control" @bind="scannedReport.Mileage" min="0" placeholder="km" />
</div>
</div>
<div class="tread-grid" role="group" aria-label="Profiltiefen">
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthFL)">
<label>Vorne links</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthFL" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthFR)">
<label>Vorne rechts</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthFR" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthRL)">
<label>Hinten links</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthRL" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthRR)">
<label>Hinten rechts</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthRR" /><span>mm</span></div>
</div>
</div>
@if (!string.IsNullOrEmpty(scannedReport.Recommendation))
{
<div class="report-recommendation">💡 @scannedReport.Recommendation</div>
}
<a class="report-link" href="@scannedReport.ReportUrl" target="_blank" rel="noopener">Original-Report öffnen ↗</a>
<div class="report-warning info">
Die Messwerte werden dem Satz zugeordnet, der beim Reifenwechsel <strong>eingelagert</strong> wird
(@(swapUseHistory ? (swapSelectedHistory?.TicketNumber ?? "Satz aus Historie") : "neu erfasster Satz")).
</div>
</div>
<div class="report-footer">
<button type="button" class="btn btn-outline" @onclick="CloseReportModal">Verwerfen</button>
<button type="button" class="btn btn-primary" @onclick="ApplySwapReport">
<span>Für Einlagerung übernehmen</span>
</button>
</div>
</div>
</div>
}
@* Action Chooser Modal *@
@@ -457,6 +576,14 @@ else
{
<p class="success-detail">Lagerplatz <strong>@swapResult.FreedLocationCode</strong> ist wieder frei.</p>
}
@if (swapReportSaved)
{
<p class="success-detail">📋 Profiltiefen-Report wurde am eingelagerten Satz gespeichert.</p>
}
@if (!string.IsNullOrEmpty(swapReportError))
{
<p class="success-detail warn">⚠️ @swapReportError</p>
}
<button class="btn btn-primary" @onclick="CloseSwapSuccess">OK</button>
</div>
</div>
@@ -536,6 +663,15 @@ else
private Guid? swapTargetLocationId => swapTargetLocation?.Id;
private SwapResult? swapResult;
// QR report for the incoming (stored) set
private bool showQrScanner;
private bool isLoadingReport;
private TireReportData? scannedReport;
private TireReportData? swapReport;
private bool swapReportSaved;
private string? swapReportError;
private const int NotesMaxLength = 1000;
private bool CanSwap => swapOutgoing != null && (swapUseHistory ? swapSelectedHistory != null : !string.IsNullOrWhiteSpace(swapNew.TireSize));
[SupplyParameterFromQuery(Name = "stackId")]
@@ -563,6 +699,111 @@ else
}
}
private static string RatingClass(TireMeasureRating r) => r switch
{
TireMeasureRating.Ok => "ok",
TireMeasureRating.Warning => "warn",
TireMeasureRating.Critical => "crit",
_ => "none"
};
private static string RatingClass(TireReportData r, decimal? mm) => RatingClass(TireInspection.RateDepth(mm, r.CriticalThreshold, r.WarningThreshold));
private static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
private RenderFragment<TireReportData> ReportSummaryFragment => report => __builder =>
{
<div class="report-summary">
@if (!string.IsNullOrEmpty(report.LicensePlate)) { <span class="report-chip">🚗 @report.LicensePlate</span> }
@if (report.Mileage.HasValue) { <span class="report-chip">@report.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km</span> }
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFL)">VL @Fmt(report.TreadDepthFL)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFR)">VR @Fmt(report.TreadDepthFR)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRL)">HL @Fmt(report.TreadDepthRL)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRR)">HR @Fmt(report.TreadDepthRR)</span>
</div>
};
private void OpenQrScanner()
{
errorMessage = null;
showQrScanner = true;
}
private async Task OnQrScanned(string text)
{
showQrScanner = false;
if (swapOutgoing == null) return;
if (!TireReportService.TryParseReportUrl(text, out var recordSn, out _))
{
errorMessage = "Der QR-Code enthält keinen gültigen Reifen-Report-Link.";
return;
}
isLoadingReport = true;
errorMessage = null;
StateHasChanged();
try
{
scannedReport = await TireReportService.FetchAsync(recordSn);
}
catch (TireReportException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = $"Report konnte nicht verarbeitet werden: {ex.Message}";
}
finally
{
isLoadingReport = false;
}
}
private void CloseReportModal() => scannedReport = null;
private void ApplySwapReport()
{
if (scannedReport == null) return;
scannedReport.RecomputeRatings();
swapReport = scannedReport;
scannedReport = null;
// Prefill the manually captured set with the measured data
if (!swapUseHistory)
{
var de = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
var lines = new List<string>();
if (!string.IsNullOrEmpty(swapReport.LicensePlate)) lines.Add($"Kennzeichen: {swapReport.LicensePlate}");
if (swapReport.Mileage.HasValue) lines.Add($"km-Stand: {swapReport.Mileage.Value.ToString("N0", de)}");
if (swapReport.HasTreadData)
lines.Add($"Profil VL {Fmt(swapReport.TreadDepthFL)} / VR {Fmt(swapReport.TreadDepthFR)} / HL {Fmt(swapReport.TreadDepthRL)} / HR {Fmt(swapReport.TreadDepthRR)}");
if (!string.IsNullOrEmpty(swapReport.Recommendation)) lines.Add(swapReport.Recommendation);
var block = string.Join("\n", lines);
if (!string.IsNullOrWhiteSpace(block))
{
var merged = string.IsNullOrWhiteSpace(swapNew.Notes) ? block : swapNew.Notes.TrimEnd() + "\n" + block;
swapNew.Notes = merged.Length <= NotesMaxLength ? merged : merged[..NotesMaxLength];
}
if (swapReport.HasTreadData)
{
var minDepth = new[] { swapReport.TreadDepthFL, swapReport.TreadDepthFR, swapReport.TreadDepthRL, swapReport.TreadDepthRR }
.Where(v => v.HasValue).Select(v => v!.Value).Min();
swapNew.Condition = minDepth < TireInspection.DefaultWarningThreshold ? TireCondition.Poor
: minDepth < 5m ? TireCondition.Fair
: TireCondition.Good;
}
}
}
private void RemoveSwapReport() => swapReport = null;
private static string SeasonIcon(TireSeason s) => s == TireSeason.Sommer ? "☀️" : s == TireSeason.Winter ? "❄️" : "🔄";
private static string SeasonLabel(TireSeason s) => s == TireSeason.Sommer ? "☀️ Sommer" : s == TireSeason.Winter ? "❄️ Winter" : "🔄 Allrad";
@@ -609,6 +850,11 @@ else
showLocationPicker = false;
swapTargetLocation = outgoing.StorageLocation;
swapSelectedHistory = null;
swapReport = null;
scannedReport = null;
showQrScanner = false;
swapReportSaved = false;
swapReportError = null;
errorMessage = null;
successMessage = null;
@@ -669,6 +915,9 @@ else
swapHistory.Clear();
swapSelectedHistory = null;
showLocationPicker = false;
showQrScanner = false;
scannedReport = null;
swapReport = null;
swapTargetLocation = null;
}
@@ -694,13 +943,31 @@ else
: swapNew;
var targetLocation = swapTargetLocationId == swapOutgoing.StorageLocationId ? null : swapTargetLocationId;
var report = swapReport;
swapResult = await TireStackService.SwapAsync(
var result = await TireStackService.SwapAsync(
swapOutgoing.Id,
incoming,
targetLocation,
UserSession.CurrentUser?.Id);
swapReportSaved = false;
swapReportError = null;
if (report != null)
{
try
{
await TireStackService.AddInspectionAsync(report.ToInspection(result.Incoming.Id, UserSession.CurrentUser?.Id));
swapReportSaved = true;
}
catch (Exception ex)
{
swapReportError = $"Reifenwechsel erfolgreich, aber der Report konnte nicht gespeichert werden: {ex.Message}";
}
}
swapResult = result;
CancelSwap();
searchResults.Clear();
searchTerm = "";