diff --git a/Lagerbestand/Components/Pages/TireCheckout.razor b/Lagerbestand/Components/Pages/TireCheckout.razor index 89bc37f..7d99a6a 100644 --- a/Lagerbestand/Components/Pages/TireCheckout.razor +++ b/Lagerbestand/Components/Pages/TireCheckout.razor @@ -4,6 +4,7 @@ @inject ICustomerService CustomerService @inject ITireStackService TireStackService @inject IStorageLocationService StorageLocationService +@inject ITireReportService TireReportService @inject UserSessionService UserSession Reifenstapel Abholen - Reifen Späth Lagerverwaltung @@ -210,6 +211,39 @@ else

Einlagerung (kommt vom Fahrzeug)

+ @* QR report scan for the incoming set *@ +
+ @if (swapReport == null) + { +
+ + Profiltiefen-Report (Launch TTS01) per QR-Code übernehmen +
+ + } + else + { +
+
+ + Report wird mit der Einlagerung gespeichert + +
+ @ReportSummaryFragment(swapReport) +
+ } +
+
+
+ +
+ @if (!scannedReport.HasTreadData) + { +
+ ⚠️ Der Report enthält keine Profiltiefen-Werte. Werte können unten manuell ergänzt werden. +
+ } + +
+
+ + +
+
+ + +
+
+ +
+
+ +
mm
+
+
+ +
mm
+
+
+ +
mm
+
+
+ +
mm
+
+
+ + @if (!string.IsNullOrEmpty(scannedReport.Recommendation)) + { +
💡 @scannedReport.Recommendation
+ } + + Original-Report öffnen ↗ + +
+ Die Messwerte werden dem Satz zugeordnet, der beim Reifenwechsel eingelagert wird + (@(swapUseHistory ? (swapSelectedHistory?.TicketNumber ?? "Satz aus Historie") : "neu erfasster Satz")). +
+
+ + +
+ } @* Action Chooser Modal *@ @@ -457,6 +576,14 @@ else {

Lagerplatz @swapResult.FreedLocationCode ist wieder frei.

} + @if (swapReportSaved) + { +

📋 Profiltiefen-Report wurde am eingelagerten Satz gespeichert.

+ } + @if (!string.IsNullOrEmpty(swapReportError)) + { +

⚠️ @swapReportError

+ } @@ -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 ReportSummaryFragment => report => __builder => + { +
+ @if (!string.IsNullOrEmpty(report.LicensePlate)) { 🚗 @report.LicensePlate } + @if (report.Mileage.HasValue) { @report.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km } + VL @Fmt(report.TreadDepthFL) + VR @Fmt(report.TreadDepthFR) + HL @Fmt(report.TreadDepthRL) + HR @Fmt(report.TreadDepthRR) +
+ }; + + 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(); + 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 = ""; diff --git a/Lagerbestand/Components/Pages/TireCheckout.razor.css b/Lagerbestand/Components/Pages/TireCheckout.razor.css index 4a14ea1..d0b2d70 100644 --- a/Lagerbestand/Components/Pages/TireCheckout.razor.css +++ b/Lagerbestand/Components/Pages/TireCheckout.razor.css @@ -1102,4 +1102,276 @@ } } +/* ======================================== + QR Report (swap flow) + ======================================== */ +.report-scan-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + background: rgba(255, 255, 255, 0.04); + border: 0.5px dashed rgba(255, 255, 255, 0.2); + border-radius: 10px; + padding: 8px 12px; + margin-bottom: 0.75rem; +} + +.report-scan-text { + display: flex; + align-items: center; + gap: 8px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.75); +} + +.report-scan-icon { + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 1.15rem; + line-height: 1; +} + +.report-scan-bar .btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.report-attached { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} + +.report-attached-head { + display: flex; + align-items: center; + gap: 8px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + color: #ffffff; +} + +.btn-link { + background: none; + border: none; + padding: 0; + color: #60a5fa; + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + cursor: pointer; + margin-left: auto; +} + +.btn-link:hover { + text-decoration: underline; +} + +.report-summary { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.report-chip { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + font-weight: 600; + padding: 2px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.85); + border: 0.5px solid rgba(255, 255, 255, 0.12); +} + +.report-chip.rating-ok { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.4); color: #86efac; } +.report-chip.rating-warn { background: rgba(250, 204, 21, 0.15); border-color: rgba(250, 204, 21, 0.4); color: #fde047; } +.report-chip.rating-crit { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; } + +.report-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1150; +} + +.report-dialog { + background: #1c1c1e; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + width: 100%; + max-width: 640px; + max-height: 92vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.report-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.1); +} + +.report-header h3 { + margin: 0; + font-family: 'Rajdhani', sans-serif; + font-size: 1.2rem; + font-weight: 700; + color: #ffffff; +} + +.report-subtitle { + margin: 0.2rem 0 0; + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.55); +} + +.report-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 1.1rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; +} + +.report-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.report-body { + padding: 1rem 1.25rem; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.report-warning { + background: rgba(250, 204, 21, 0.1); + border: 0.5px solid rgba(250, 204, 21, 0.35); + color: #fde047; + border-radius: 10px; + padding: 10px 14px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; +} + +.report-warning.info { + background: rgba(96, 165, 250, 0.1); + border-color: rgba(96, 165, 250, 0.3); + color: rgba(255, 255, 255, 0.85); +} + +.report-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.report-meta .form-group { + margin: 0; +} + +.tread-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.tread-cell { + border-radius: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.tread-cell.rating-ok { border-color: rgba(34, 197, 94, 0.5); background: rgba(34, 197, 94, 0.08); } +.tread-cell.rating-warn { border-color: rgba(250, 204, 21, 0.5); background: rgba(250, 204, 21, 0.08); } +.tread-cell.rating-crit { border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.08); } + +.tread-cell label { + display: block; + font-family: 'Rajdhani', sans-serif; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 6px; +} + +.tread-input { + display: flex; + align-items: center; + gap: 6px; +} + +.tread-input .form-control { + max-width: 110px; + font-family: 'Orbitron', monospace; + font-weight: 600; + background: rgba(255, 255, 255, 0.06); + border: 0.5px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + color: #ffffff; + padding: 6px 10px; +} + +.tread-input span { + font-family: 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.6); +} + +.report-recommendation { + font-family: 'Rajdhani', sans-serif; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.85); + background: rgba(96, 165, 250, 0.1); + border: 0.5px solid rgba(96, 165, 250, 0.25); + border-radius: 10px; + padding: 10px 14px; +} + +.report-link { + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + color: #60a5fa; + align-self: flex-start; +} + +.report-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 0.85rem 1.25rem; + border-top: 0.5px solid rgba(255, 255, 255, 0.1); +} + +.success-detail.warn { + color: #fde047; +} + +@media (max-width: 479px) { + .report-meta, + .tread-grid { + grid-template-columns: 1fr; + } +} + /* Reduced motion: inherited from app.css (section 17) */