Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes
This commit is contained in:
1 parent
cba9414e92
commit
3404fb9875
25 files changed
+5106
-744
No files matched your search
@@ -5,6 +5,7 @@
|
||||
@inject IStorageLocationService StorageLocationService
|
||||
@inject ILocationCodeService LocationCodeService
|
||||
@inject ITireStackService TireStackService
|
||||
@inject ITireReportService TireReportService
|
||||
@inject ICatalogService CatalogService
|
||||
@inject IFileStorageService FileStorageService
|
||||
@inject UserSessionService UserSession
|
||||
@@ -474,6 +475,42 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isInventoryMode)
|
||||
{
|
||||
@* QR report scan *@
|
||||
<div class="report-scan-bar">
|
||||
@if (pendingReport == 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-outline" @onclick="OpenQrScanner" disabled="@isLoadingReport">
|
||||
@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 dem neuen Stapel gespeichert</strong>
|
||||
<button type="button" class="btn-link" @onclick="RemovePendingReport">Entfernen</button>
|
||||
</div>
|
||||
@ReportSummaryFragment(pendingReport)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Tire/Felgen Form *@
|
||||
<div class="tire-form">
|
||||
@if (!isFelgenCategory)
|
||||
@@ -506,6 +543,10 @@ else
|
||||
else
|
||||
{
|
||||
<TireSizeInput Value="@tireStack.TireSize" ValueChanged="OnTireSizeChanged" />
|
||||
@if (tireSizeFromVehicle && !string.IsNullOrEmpty(tireStack.TireSize))
|
||||
{
|
||||
<span class="field-hint">🚗 Vom Fahrzeug übernommen</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -657,7 +698,7 @@ else
|
||||
@* Step 3: Storage Location *@
|
||||
@if (currentStep == 3)
|
||||
{
|
||||
<div class="checkin-card">
|
||||
<div class="checkin-card wide">
|
||||
<div class="selected-info-bar">
|
||||
@if (isInventoryMode)
|
||||
{
|
||||
@@ -676,32 +717,104 @@ else
|
||||
|
||||
<h2>Lagerplatz auswählen</h2>
|
||||
|
||||
<div class="location-selector">
|
||||
@* Area Filter *@
|
||||
<div class="area-filter">
|
||||
<button class="filter-btn @(selectedAreaFilter == null ? "active" : "")"
|
||||
@onclick="() => FilterLocationsByArea(null)">
|
||||
Alle
|
||||
</button>
|
||||
<button class="filter-btn lager @(selectedAreaFilter == AreaType.Lager ? "active" : "")"
|
||||
@onclick="() => FilterLocationsByArea(AreaType.Lager)">
|
||||
🏭 Lager
|
||||
</button>
|
||||
<button class="filter-btn werkstatt @(selectedAreaFilter == AreaType.Werkstatt ? "active" : "")"
|
||||
@onclick="() => FilterLocationsByArea(AreaType.Werkstatt)">
|
||||
🔧 Werkstatt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* Row Filter *@
|
||||
<div class="row-filter">
|
||||
<select class="form-control" @bind="selectedRowFilter" @bind:after="ApplyLocationFilter">
|
||||
<option value="">Alle Reihen</option>
|
||||
@for (int r = StorageLocation.MinRow; r <= StorageLocation.MaxRowLager; r++)
|
||||
@if (selectedLocation != null)
|
||||
{
|
||||
<div class="checkin-summary">
|
||||
<h3 class="summary-title">📋 Zusammenfassung</h3>
|
||||
<div class="summary-grid">
|
||||
@if (isInventoryMode)
|
||||
{
|
||||
<option value="@r">Reihe @r</option>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Kategorie</span>
|
||||
<span class="summary-value">📦 @selectedCategory?.Name</span>
|
||||
</div>
|
||||
}
|
||||
</select>
|
||||
else
|
||||
{
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Kunde</span>
|
||||
<span class="summary-value">👤 @selectedCustomer?.FullName</span>
|
||||
</div>
|
||||
@if (selectedVehicle != null)
|
||||
{
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Fahrzeug</span>
|
||||
<span class="summary-value">🚗 @selectedVehicle.DisplayName</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">@(isFelgenCategory ? "Felgen" : "Reifen")</span>
|
||||
<span class="summary-value">
|
||||
@(isInventoryMode && useCustomItemCount ? tireStack.ItemCount : TireStack.TiresPerStack) Stück
|
||||
@if (!isFelgenCategory)
|
||||
{
|
||||
<text> @(tireStack.Season == TireSeason.Sommer ? "☀️" : tireStack.Season == TireSeason.Winter ? "❄️" : "🔄") @tireStack.Season</text>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(tireStack.TireSize))
|
||||
{
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Größe</span>
|
||||
<span class="summary-value">@tireStack.TireSize @(tireStack.WithRims && !isFelgenCategory ? "| mit Felgen" : "")</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(tireStack.TireBrand) || !string.IsNullOrEmpty(tireStack.TireModel))
|
||||
{
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Marke</span>
|
||||
<span class="summary-value">@tireStack.TireBrand @tireStack.TireModel</span>
|
||||
</div>
|
||||
}
|
||||
<div class="summary-item highlight">
|
||||
<span class="summary-label">Lagerplatz</span>
|
||||
<span class="summary-value location">
|
||||
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" ShowIcon="true" Size="LocationDisplaySize.Small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-clear summary-clear" @onclick="ClearSelectedLocation" title="Lagerplatz-Auswahl aufheben">✕</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (locationSelectorCollapsed && selectedLocation != null)
|
||||
{
|
||||
<button type="button" class="location-collapsed-bar" @onclick="ExpandLocationSelector" title="Lagerplatz ändern">
|
||||
<span class="collapsed-icon">📍</span>
|
||||
<span class="collapsed-code">@selectedLocation.Code</span>
|
||||
<span class="collapsed-hint">Lagerplatz ändern ▾</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="location-selector compact">
|
||||
@* Area & Row Filter combined *@
|
||||
<div class="location-filter-row">
|
||||
<div class="area-filter">
|
||||
<button class="filter-btn @(selectedAreaFilter == null ? "active" : "")"
|
||||
@onclick="() => FilterLocationsByArea(null)">
|
||||
Alle
|
||||
</button>
|
||||
<button class="filter-btn lager @(selectedAreaFilter == AreaType.Lager ? "active" : "")"
|
||||
@onclick="() => FilterLocationsByArea(AreaType.Lager)">
|
||||
🏭 Lager
|
||||
</button>
|
||||
<button class="filter-btn werkstatt @(selectedAreaFilter == AreaType.Werkstatt ? "active" : "")"
|
||||
@onclick="() => FilterLocationsByArea(AreaType.Werkstatt)">
|
||||
🔧 Werkstatt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row-filter">
|
||||
<select class="form-control" @bind="selectedRowFilter" @bind:after="ApplyLocationFilter">
|
||||
<option value="">Alle Reihen</option>
|
||||
@for (int r = StorageLocation.MinRow; r <= StorageLocation.MaxRowLager; r++)
|
||||
{
|
||||
<option value="@r">Reihe @r</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Location Grid *@
|
||||
@@ -720,7 +833,7 @@ else
|
||||
<h3 class="area-title">
|
||||
@(areaGroup.Key == AreaType.Lager ? "🏭" : "🔧") @areaGroup.Key.GetDisplayName()
|
||||
</h3>
|
||||
|
||||
|
||||
@foreach (var rowGroup in areaGroup.GroupBy(l => l.Row).OrderBy(g => g.Key))
|
||||
{
|
||||
<div class="row-section">
|
||||
@@ -742,16 +855,6 @@ else
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedLocation != null)
|
||||
{
|
||||
<div class="selected-location">
|
||||
<div class="location-icon">📍</div>
|
||||
<div class="location-info">
|
||||
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" ShowIcon="false" />
|
||||
</div>
|
||||
<button class="btn-clear" @onclick="ClearSelectedLocation">✕</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
@@ -873,6 +976,159 @@ else
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<QrScannerModal @bind-IsOpen="showQrScanner"
|
||||
Title="Reifen-Report scannen"
|
||||
Subtitle="@(selectedCustomer != null ? $"Kunde: {selectedCustomer.FullName}" : null)"
|
||||
OnScanned="OnQrScanned" />
|
||||
|
||||
@* Report preview / assignment modal *@
|
||||
@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-choice">
|
||||
<h4>Wie sollen die Daten verwendet werden?</h4>
|
||||
|
||||
<div class="report-choice-buttons">
|
||||
<button type="button" class="choice-btn @(reportMode == ReportAssignMode.Existing ? "active" : "")"
|
||||
@onclick="() => SetReportMode(ReportAssignMode.Existing)"
|
||||
disabled="@(customerStacksForReport.Count == 0)">
|
||||
<span class="choice-icon">📦</span>
|
||||
<span class="choice-title">Zu vorhandenem Satz</span>
|
||||
<span class="choice-sub">@(customerStacksForReport.Count == 0 ? "Keine Sätze vorhanden" : $"{customerStacksForReport.Count} Satz/Sätze des Kunden")</span>
|
||||
</button>
|
||||
<button type="button" class="choice-btn @(reportMode == ReportAssignMode.New ? "active" : "")"
|
||||
@onclick="() => SetReportMode(ReportAssignMode.New)">
|
||||
<span class="choice-icon">✨</span>
|
||||
<span class="choice-title">Neuen Satz anlegen</span>
|
||||
<span class="choice-sub">Daten ins Formular übernehmen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (reportMode == ReportAssignMode.Existing)
|
||||
{
|
||||
<div class="stack-pick-list" role="list">
|
||||
@foreach (var s in customerStacksForReport)
|
||||
{
|
||||
<button type="button" class="stack-pick-item @(reportTargetStackId == s.Id ? "selected" : "")" role="listitem"
|
||||
@onclick="() => reportTargetStackId = s.Id">
|
||||
<span class="stack-pick-main">
|
||||
<span class="mono">@s.TicketNumber</span>
|
||||
<span>@(s.Season == TireSeason.Sommer ? "☀️" : s.Season == TireSeason.Winter ? "❄️" : "🔄") @s.Season</span>
|
||||
@if (!string.IsNullOrEmpty(s.TireSize)) { <span>@s.TireSize</span> }
|
||||
</span>
|
||||
<span class="stack-pick-sub">
|
||||
@s.StatusText
|
||||
@if (s.Vehicle != null) { <text> · 🚗 @s.Vehicle.ShortName</text> }
|
||||
@if (s.StorageLocation != null) { <text> · @s.StorageLocation.Code</text> }
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(reportError))
|
||||
{
|
||||
<div class="report-warning">⚠️ @reportError</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="report-footer">
|
||||
<button type="button" class="btn btn-outline" @onclick="CloseReportModal">Abbrechen</button>
|
||||
@if (reportMode == ReportAssignMode.Existing)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="AttachReportToExisting" disabled="@(reportTargetStackId == null || isProcessing)">
|
||||
@if (isProcessing) { <span class="spinner"></span> }
|
||||
<span>Zum Satz hinzufügen</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="ApplyReportToNew">
|
||||
<span>Für neuen Satz übernehmen</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Report attached to existing stack – success *@
|
||||
@if (reportAttachedTo != null)
|
||||
{
|
||||
<div class="report-overlay" @onclick="() => reportAttachedTo = null">
|
||||
<div class="report-dialog report-success" role="dialog" aria-modal="true" @onclick:stopPropagation="true">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>Report gespeichert</h3>
|
||||
<p>Die Messwerte wurden dem Satz <span class="mono">@reportAttachedTo.TicketNumber</span> hinzugefügt.</p>
|
||||
<div class="report-footer center">
|
||||
<button type="button" class="btn btn-outline" @onclick="() => reportAttachedTo = null">Weiter erfassen</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="ResetForm">Fertig</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -923,6 +1179,179 @@ else
|
||||
private string? zoomImageUrl;
|
||||
private string? zoomImageTitle;
|
||||
|
||||
// QR report
|
||||
private enum ReportAssignMode { Existing, New }
|
||||
private bool showQrScanner;
|
||||
private bool isLoadingReport;
|
||||
private TireReportData? scannedReport;
|
||||
private TireReportData? pendingReport;
|
||||
private ReportAssignMode reportMode = ReportAssignMode.New;
|
||||
private List<TireStack> customerStacksForReport = new();
|
||||
private Guid? reportTargetStackId;
|
||||
private string? reportError;
|
||||
private TireStack? reportAttachedTo;
|
||||
private const int NotesMaxLength = 1000;
|
||||
|
||||
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 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 static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
|
||||
|
||||
private void OpenQrScanner()
|
||||
{
|
||||
reportError = null;
|
||||
showQrScanner = true;
|
||||
}
|
||||
|
||||
private async Task OnQrScanned(string text)
|
||||
{
|
||||
showQrScanner = false;
|
||||
|
||||
if (selectedCustomer == null)
|
||||
{
|
||||
errorMessage = "Bitte zuerst einen Kunden auswählen.";
|
||||
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
|
||||
{
|
||||
var report = await TireReportService.FetchAsync(recordSn);
|
||||
|
||||
var stacks = await TireStackService.GetByCustomerAsync(selectedCustomer.Id);
|
||||
customerStacksForReport = stacks
|
||||
.Where(s => s.IsActive && s.Status != TireStackStatus.PickedUp)
|
||||
.OrderByDescending(s => selectedVehicle != null && s.VehicleId == selectedVehicle.Id)
|
||||
.ThenByDescending(s => s.Status == TireStackStatus.Stored)
|
||||
.ThenByDescending(s => s.StoredAt)
|
||||
.ToList();
|
||||
|
||||
reportMode = customerStacksForReport.Count > 0 ? ReportAssignMode.Existing : ReportAssignMode.New;
|
||||
reportTargetStackId = customerStacksForReport.FirstOrDefault()?.Id;
|
||||
reportError = null;
|
||||
scannedReport = report;
|
||||
}
|
||||
catch (TireReportException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Report konnte nicht verarbeitet werden: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoadingReport = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetReportMode(ReportAssignMode mode)
|
||||
{
|
||||
reportMode = mode;
|
||||
reportError = null;
|
||||
}
|
||||
|
||||
private void CloseReportModal()
|
||||
{
|
||||
scannedReport = null;
|
||||
reportError = null;
|
||||
}
|
||||
|
||||
private async Task AttachReportToExisting()
|
||||
{
|
||||
if (scannedReport == null || reportTargetStackId == null) return;
|
||||
|
||||
isProcessing = true;
|
||||
reportError = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
scannedReport.RecomputeRatings();
|
||||
await TireStackService.AddInspectionAsync(scannedReport.ToInspection(reportTargetStackId.Value, UserSession.CurrentUser?.Id));
|
||||
reportAttachedTo = customerStacksForReport.FirstOrDefault(s => s.Id == reportTargetStackId.Value);
|
||||
scannedReport = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
reportError = $"Speichern fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyReportToNew()
|
||||
{
|
||||
if (scannedReport == null) return;
|
||||
|
||||
scannedReport.RecomputeRatings();
|
||||
pendingReport = scannedReport;
|
||||
scannedReport = null;
|
||||
|
||||
var de = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
|
||||
var lines = new List<string>();
|
||||
if (!string.IsNullOrEmpty(pendingReport.LicensePlate)) lines.Add($"Kennzeichen: {pendingReport.LicensePlate}");
|
||||
if (pendingReport.Mileage.HasValue) lines.Add($"km-Stand: {pendingReport.Mileage.Value.ToString("N0", de)}");
|
||||
if (pendingReport.HasTreadData)
|
||||
lines.Add($"Profil VL {Fmt(pendingReport.TreadDepthFL)} / VR {Fmt(pendingReport.TreadDepthFR)} / HL {Fmt(pendingReport.TreadDepthRL)} / HR {Fmt(pendingReport.TreadDepthRR)}");
|
||||
if (!string.IsNullOrEmpty(pendingReport.Recommendation)) lines.Add(pendingReport.Recommendation);
|
||||
|
||||
var block = string.Join("\n", lines);
|
||||
if (!string.IsNullOrWhiteSpace(block))
|
||||
{
|
||||
var merged = string.IsNullOrWhiteSpace(tireStack.Notes) ? block : tireStack.Notes.TrimEnd() + "\n" + block;
|
||||
tireStack.Notes = merged.Length <= NotesMaxLength ? merged : merged[..NotesMaxLength];
|
||||
showDetailsExpanded = true;
|
||||
}
|
||||
|
||||
if (pendingReport.HasTreadData)
|
||||
{
|
||||
var minDepth = new[] { pendingReport.TreadDepthFL, pendingReport.TreadDepthFR, pendingReport.TreadDepthRL, pendingReport.TreadDepthRR }
|
||||
.Where(v => v.HasValue).Select(v => v!.Value).Min();
|
||||
tireStack.Condition = minDepth < TireInspection.DefaultWarningThreshold ? TireCondition.Poor
|
||||
: minDepth < 5m ? TireCondition.Fair
|
||||
: TireCondition.Good;
|
||||
}
|
||||
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
|
||||
private void RemovePendingReport()
|
||||
{
|
||||
pendingReport = null;
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
|
||||
// Computed helpers
|
||||
private bool isFelgenCategory => isInventoryMode && selectedCategory?.Type == CatalogItemType.Felgen;
|
||||
|
||||
@@ -954,8 +1383,16 @@ else
|
||||
// Cleared on successful CompleteCheckin and on ResetForm.
|
||||
// ------------------------------------------------------------------
|
||||
private const string DraftKey = "tire-checkin";
|
||||
private static readonly System.Text.Json.JsonSerializerOptions DraftJsonOptions = new()
|
||||
{
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
private bool _draftRestoreAttempted;
|
||||
private bool _isRestoringDraft;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "customerId")]
|
||||
public Guid? PreselectedCustomerId { get; set; }
|
||||
private CancellationTokenSource? _draftDebounceCts;
|
||||
private CancellationTokenSource? _searchDebounceCts;
|
||||
private readonly SemaphoreSlim _searchLock = new(1, 1);
|
||||
@@ -965,10 +1402,37 @@ else
|
||||
if (firstRender && UserSession.IsWorkerOrAdmin && !_draftRestoreAttempted)
|
||||
{
|
||||
_draftRestoreAttempted = true;
|
||||
|
||||
if (PreselectedCustomerId.HasValue)
|
||||
{
|
||||
await PreselectCustomerAsync(PreselectedCustomerId.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
await TryRestoreDraftAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreselectCustomerAsync(Guid customerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = await CustomerService.GetByIdAsync(customerId);
|
||||
if (customer == null) return;
|
||||
|
||||
try { await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey); } catch { }
|
||||
|
||||
isInventoryMode = false;
|
||||
await SelectCustomer(customer);
|
||||
GoToStep2();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Kunde konnte nicht geladen werden: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryRestoreDraftAsync()
|
||||
{
|
||||
try
|
||||
@@ -976,7 +1440,7 @@ else
|
||||
var json = await JS.InvokeAsync<string?>("appInterop.loadDraft", DraftKey);
|
||||
if (string.IsNullOrWhiteSpace(json)) return;
|
||||
|
||||
var draft = System.Text.Json.JsonSerializer.Deserialize<CheckinDraft>(json);
|
||||
var draft = System.Text.Json.JsonSerializer.Deserialize<CheckinDraft>(json, DraftJsonOptions);
|
||||
if (draft == null) return;
|
||||
|
||||
_isRestoringDraft = true;
|
||||
@@ -1012,6 +1476,8 @@ else
|
||||
tireStack = draft.TireStack;
|
||||
}
|
||||
|
||||
pendingReport = draft.PendingReport;
|
||||
|
||||
if (draft.SelectedLocationId.HasValue)
|
||||
{
|
||||
selectedLocation = allLocations.FirstOrDefault(l => l.Id == draft.SelectedLocationId.Value);
|
||||
@@ -1083,10 +1549,11 @@ else
|
||||
SelectedLocationId = selectedLocation?.Id,
|
||||
UseCustomItemCount = useCustomItemCount,
|
||||
ShowDetailsExpanded = showDetailsExpanded,
|
||||
TireStack = tireStack
|
||||
TireStack = tireStack,
|
||||
PendingReport = pendingReport
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(draft);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(draft, DraftJsonOptions);
|
||||
await JS.InvokeVoidAsync("appInterop.saveDraft", DraftKey, json);
|
||||
await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft());
|
||||
}
|
||||
@@ -1127,6 +1594,7 @@ else
|
||||
public bool UseCustomItemCount { get; set; }
|
||||
public bool ShowDetailsExpanded { get; set; }
|
||||
public TireStack? TireStack { get; set; }
|
||||
public TireReportData? PendingReport { get; set; }
|
||||
}
|
||||
|
||||
private async Task LoadLocations()
|
||||
@@ -1251,6 +1719,8 @@ else
|
||||
selectedVehicle = null;
|
||||
}
|
||||
|
||||
ApplyVehicleTireSize();
|
||||
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
|
||||
@@ -1258,9 +1728,26 @@ else
|
||||
{
|
||||
selectedVehicle = vehicle;
|
||||
showAddVehicleForm = false;
|
||||
ApplyVehicleTireSize();
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Übernimmt die Reifengröße des gewählten Fahrzeugs in den Reifenstapel,
|
||||
/// sofern noch keine (abweichende) Größe manuell erfasst wurde.
|
||||
/// </summary>
|
||||
private void ApplyVehicleTireSize()
|
||||
{
|
||||
if (isInventoryMode) return;
|
||||
if (string.IsNullOrWhiteSpace(selectedVehicle?.TireSize)) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tireStack.TireSize) || tireSizeFromVehicle)
|
||||
{
|
||||
OnTireSizeChanged(selectedVehicle.TireSize);
|
||||
tireSizeFromVehicle = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNewVehicleForm()
|
||||
{
|
||||
showAddVehicleForm = true;
|
||||
@@ -1285,6 +1772,7 @@ else
|
||||
showAddVehicleForm = false;
|
||||
newVehicle = new Vehicle();
|
||||
errorMessage = null;
|
||||
ApplyVehicleTireSize();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1344,6 +1832,7 @@ else
|
||||
newCustomer = new Customer();
|
||||
newVehicle = new Vehicle();
|
||||
errorMessage = null;
|
||||
ApplyVehicleTireSize();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1441,6 +1930,7 @@ else
|
||||
private async Task GoToStep3()
|
||||
{
|
||||
await LoadLocations();
|
||||
locationSelectorCollapsed = selectedLocation != null;
|
||||
currentStep = 3;
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
@@ -1451,18 +1941,27 @@ else
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
|
||||
private bool locationSelectorCollapsed;
|
||||
|
||||
private void SelectLocation(StorageLocation location)
|
||||
{
|
||||
if (!location.IsOccupied && location.IsAvailable)
|
||||
{
|
||||
selectedLocation = location;
|
||||
locationSelectorCollapsed = true;
|
||||
_ = SaveDraftAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void ExpandLocationSelector()
|
||||
{
|
||||
locationSelectorCollapsed = false;
|
||||
}
|
||||
|
||||
private void ClearSelectedLocation()
|
||||
{
|
||||
selectedLocation = null;
|
||||
locationSelectorCollapsed = false;
|
||||
}
|
||||
|
||||
private async Task CompleteCheckin()
|
||||
@@ -1501,6 +2000,19 @@ else
|
||||
|
||||
createdTireStack = created;
|
||||
|
||||
if (pendingReport != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TireStackService.AddInspectionAsync(pendingReport.ToInspection(created.Id, UserSession.CurrentUser?.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Stapel eingelagert, Report konnte aber nicht gespeichert werden: {ex.Message}";
|
||||
}
|
||||
pendingReport = null;
|
||||
}
|
||||
|
||||
currentStep = 4;
|
||||
await ClearDraftAsync();
|
||||
}
|
||||
@@ -1533,9 +2045,11 @@ else
|
||||
showAddVehicleForm = false;
|
||||
tireStack = new TireStack();
|
||||
showDetailsExpanded = false;
|
||||
tireSizeFromVehicle = false;
|
||||
selectedLocation = null;
|
||||
selectedAreaFilter = null;
|
||||
selectedRowFilter = null;
|
||||
locationSelectorCollapsed = false;
|
||||
createdTireStack = null;
|
||||
errorMessage = null;
|
||||
useCustomItemCount = false;
|
||||
@@ -1544,6 +2058,13 @@ else
|
||||
showImageZoom = false;
|
||||
zoomImageUrl = null;
|
||||
zoomImageTitle = null;
|
||||
showQrScanner = false;
|
||||
scannedReport = null;
|
||||
pendingReport = null;
|
||||
reportAttachedTo = null;
|
||||
reportError = null;
|
||||
customerStacksForReport.Clear();
|
||||
reportTargetStackId = null;
|
||||
_ = ClearDraftAsync();
|
||||
}
|
||||
|
||||
@@ -1563,8 +2084,14 @@ else
|
||||
tireStack.RimSize = isAlu ? "Alu" : "Stahl";
|
||||
}
|
||||
|
||||
private bool tireSizeFromVehicle;
|
||||
|
||||
private void OnTireSizeChanged(string? value)
|
||||
{
|
||||
if (tireStack.TireSize != value && value != selectedVehicle?.TireSize)
|
||||
{
|
||||
tireSizeFromVehicle = false;
|
||||
}
|
||||
tireStack.TireSize = value;
|
||||
|
||||
// Auto-derive rim size from the R value of the tire size (e.g. "225/45 R17" -> "17 Zoll")
|
||||
|
||||
Reference in new issue
Block a user