Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes

This commit is contained in:
spaethf committed 2026-09-26 23:24:14 +02:00
1 parent cba9414e92
commit 3404fb9875
25 files changed
+5106 -744

No files matched your search

@@ -1,8 +1,9 @@
@page "/tire-checkout"
@using Lagerbestand.Components.Shared
@implements IDisposable
@implements IAsyncDisposable
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject IStorageLocationService StorageLocationService
@inject UserSessionService UserSession
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
@@ -169,6 +170,164 @@ else
</div>
}
@* Tire Swap Panel *@
@if (swapOutgoing != null)
{
<section class="swap-panel" aria-labelledby="swap-heading">
<header class="swap-header">
<h2 id="swap-heading"><span aria-hidden="true">🔄</span> Reifenwechsel</h2>
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelSwap" disabled="@isProcessing">Abbrechen</button>
</header>
<div class="swap-columns">
@* Outgoing *@
<div class="swap-box outgoing">
<h3 class="swap-box-title"><span aria-hidden="true">⬆️</span> Herausgabe (wird montiert)</h3>
<dl class="swap-rows">
<div class="swap-row"><dt>Ticket</dt><dd class="mono">@swapOutgoing.TicketNumber</dd></div>
<div class="swap-row"><dt>Kunde</dt><dd>@swapOutgoing.Customer?.FullName</dd></div>
@if (swapOutgoing.Vehicle != null)
{
<div class="swap-row"><dt>Fahrzeug</dt><dd>@swapOutgoing.Vehicle.Make @swapOutgoing.Vehicle.Model</dd></div>
}
<div class="swap-row"><dt>Saison</dt><dd>@SeasonLabel(swapOutgoing.Season)</dd></div>
<div class="swap-row"><dt>Größe</dt><dd class="mono">@(swapOutgoing.TireSize ?? "-")</dd></div>
<div class="swap-row"><dt>Marke / Modell</dt><dd>@(string.IsNullOrWhiteSpace(swapOutgoing.TireBrand) && string.IsNullOrWhiteSpace(swapOutgoing.TireModel) ? "-" : $"{swapOutgoing.TireBrand} {swapOutgoing.TireModel}".Trim())</dd></div>
<div class="swap-row"><dt>Lagerplatz</dt><dd>
@if (swapOutgoing.StorageLocation != null)
{
<LocationDisplay Code="@swapOutgoing.StorageLocation.Code" DisplayMode="LocationDisplayMode.Compact" Size="LocationDisplaySize.Small" />
}
else
{
<span>-</span>
}
</dd></div>
</dl>
</div>
@* Incoming *@
<div class="swap-box incoming">
<h3 class="swap-box-title"><span aria-hidden="true">⬇️</span> Einlagerung (kommt vom Fahrzeug)</h3>
<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)">
Aus Historie (@swapHistory.Count)
</button>
<button type="button" class="swap-tab @(!swapUseHistory ? "active" : "")" role="tab" aria-selected="@(!swapUseHistory)"
@onclick="() => swapUseHistory = false">
Neu erfassen
</button>
</div>
@if (swapUseHistory)
{
<div class="history-list" role="list">
@foreach (var h in swapHistory)
{
var isSameSeason = h.Season == swapOutgoing.Season;
<button type="button" class="history-item @(swapSelectedHistory?.Id == h.Id ? "selected" : "") @(isSameSeason ? "same-season" : "")"
role="listitem" @onclick="() => swapSelectedHistory = h">
<span class="history-season">@SeasonIcon(h.Season)</span>
<span class="history-main">
<span class="mono">@h.TicketNumber</span>
<span class="history-detail">@(h.TireSize ?? "-") @(string.IsNullOrWhiteSpace(h.TireBrand) ? "" : $"| {h.TireBrand} {h.TireModel}".Trim()) @(h.WithRims ? "| mit Felgen" : "")</span>
<span class="history-meta">@h.StatusText @(h.PickedUpAt.HasValue ? $"seit {h.PickedUpAt.Value.ToLocalTime():dd.MM.yyyy}" : "")</span>
</span>
@if (isSameSeason)
{
<span class="history-warn" title="Gleiche Saison wie Herausgabe">⚠️</span>
}
</button>
}
</div>
}
else
{
<div class="form-grid">
<div class="form-group">
<label for="swap-season">Saison</label>
<select id="swap-season" class="form-control" @bind="swapNew.Season">
<option value="@TireSeason.Sommer">☀️ Sommer</option>
<option value="@TireSeason.Winter">❄️ Winter</option>
<option value="@TireSeason.Allrad">🔄 Allrad</option>
</select>
</div>
<div class="form-group">
<label for="swap-size">Reifengröße</label>
<input id="swap-size" class="form-control" @bind="swapNew.TireSize" placeholder="z.B. 225/45R17" />
</div>
<div class="form-group">
<label for="swap-brand">Marke</label>
<input id="swap-brand" class="form-control" @bind="swapNew.TireBrand" placeholder="z.B. Michelin" />
</div>
<div class="form-group">
<label for="swap-model">Modell</label>
<input id="swap-model" class="form-control" @bind="swapNew.TireModel" placeholder="z.B. Pilot Sport 4" />
</div>
<div class="form-group">
<label for="swap-rims">Felgen</label>
<select id="swap-rims" class="form-control" @bind="swapNew.WithRims">
<option value="true">Mit Felgen</option>
<option value="false">Ohne Felgen</option>
</select>
</div>
<div class="form-group">
<label for="swap-condition">Zustand</label>
<select id="swap-condition" class="form-control" @bind="swapNew.Condition">
<option value="@TireCondition.Excellent">Sehr gut</option>
<option value="@TireCondition.Good">Gut</option>
<option value="@TireCondition.Fair">Ausreichend</option>
<option value="@TireCondition.Poor">Mangelhaft</option>
<option value="@TireCondition.Critical">Kritisch</option>
</select>
</div>
<div class="form-group form-group-wide">
<label for="swap-notes">Notizen</label>
<textarea id="swap-notes" class="form-control" rows="2" @bind="swapNew.Notes"></textarea>
</div>
</div>
}
</div>
</div>
@* Target location *@
<div class="swap-location">
<span class="swap-location-label">Lagerplatz für Einlagerung:</span>
@if (swapTargetLocation != null)
{
<LocationDisplay Code="@swapTargetLocation.Code" DisplayMode="LocationDisplayMode.Compact" Size="LocationDisplaySize.Small" />
@if (swapTargetLocation.Id == swapOutgoing.StorageLocationId)
{
<span class="swap-location-hint">(frei werdender Platz)</span>
}
else
{
<button type="button" class="btn btn-sm btn-outline" @onclick="ResetTargetLocation">Zurück zum alten Platz</button>
}
}
else
{
<span class="swap-location-hint">Kein Lagerplatz gewählt</span>
}
<button type="button" class="btn btn-sm btn-outline" @onclick="() => showLocationPicker = true">
<span aria-hidden="true">▦</span> Anderen Platz wählen
</button>
</div>
<div class="swap-actions">
<button type="button" class="btn btn-primary btn-lg w-100" @onclick="ProcessSwap" disabled="@(isProcessing || !CanSwap)">
@if (isProcessing)
{
<span class="spinner"></span>
}
<span>Reifenwechsel durchführen</span>
</button>
</div>
</section>
}
@* Search Results *@
@if (searchResults.Count > 0)
{
@@ -182,7 +341,7 @@ else
{
var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id);
<div class="tireset-card @(isAlreadyScanned ? "already-scanned" : "")"
@onclick="() => AddToScannedList(ts)" role="listitem">
@onclick="() => OnResultClicked(ts)" role="listitem">
<div class="tireset-header">
<span class="ticket-number">@ts.TicketNumber</span>
<span class="status-badge @ts.Status.ToString().ToLower()">
@@ -235,6 +394,74 @@ else
</div>
}
@* Storage location picker (rack view) for swap *@
@if (swapOutgoing != null)
{
<StorageLocationPickerModal @bind-IsOpen="showLocationPicker"
SelectedLocationId="@swapTargetLocation?.Id"
HighlightLocationId="@swapOutgoing.StorageLocationId"
Subtitle="@($"Einlagerung für {swapOutgoing.Customer?.FullName} – {SeasonLabel(swapUseHistory ? (swapSelectedHistory?.Season ?? swapNew.Season) : swapNew.Season)}")"
OnSelected="OnTargetLocationSelected" />
}
@* Action Chooser Modal *@
@if (actionCandidate != null)
{
<div class="success-overlay" @onclick="CloseActionChooser">
<div class="success-modal action-modal" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="action-title">
<h2 id="action-title">Aktion wählen</h2>
<p class="action-subject">
<strong>@actionCandidate.Customer?.FullName</strong>
@if (actionCandidate.Vehicle != null)
{
<span class="vehicle-info">@actionCandidate.Vehicle.Make @actionCandidate.Vehicle.Model</span>
}
<br />
<span class="mono">@actionCandidate.TicketNumber</span> · @SeasonLabel(actionCandidate.Season) @(actionCandidate.TireSize != null ? $"· {actionCandidate.TireSize}" : "")
</p>
<div class="action-buttons">
<button type="button" class="action-btn" @onclick="ChoosePickup">
<span class="action-icon" aria-hidden="true">📤</span>
<span class="action-label">Abholen</span>
<span class="action-desc">Kunde nimmt die Reifen mit</span>
</button>
<button type="button" class="action-btn swap" @onclick="ChooseSwap">
<span class="action-icon" aria-hidden="true">🔄</span>
<span class="action-label">Reifenwechsel</span>
<span class="action-desc">Satz montieren, anderen Satz einlagern</span>
</button>
</div>
<button type="button" class="btn btn-outline" @onclick="CloseActionChooser">Abbrechen</button>
</div>
</div>
}
@* Swap Success Modal *@
@if (swapResult != null)
{
<div class="success-overlay" @onclick="CloseSwapSuccess">
<div class="success-modal" @onclick:stopPropagation="true">
<div class="success-icon">✓</div>
<h2>Reifenwechsel erfolgreich!</h2>
<div class="pickup-list" role="list">
<div class="pickup-item" role="listitem">
<span class="ticket">⬆️ @swapResult.Outgoing.TicketNumber</span>
<span class="info">@SeasonLabel(swapResult.Outgoing.Season) montiert</span>
</div>
<div class="pickup-item" role="listitem">
<span class="ticket">⬇️ @swapResult.Incoming.TicketNumber</span>
<span class="info">@SeasonLabel(swapResult.Incoming.Season) eingelagert auf <strong>@swapResult.NewLocationCode</strong></span>
</div>
</div>
@if (swapResult.FreedLocationCode != null && swapResult.FreedLocationCode != swapResult.NewLocationCode)
{
<p class="success-detail">Lagerplatz <strong>@swapResult.FreedLocationCode</strong> ist wieder frei.</p>
}
<button class="btn btn-primary" @onclick="CloseSwapSuccess">OK</button>
</div>
</div>
}
@* Success Modal *@
@if (showSuccess)
{
@@ -297,6 +524,200 @@ else
private CancellationTokenSource? _searchDebounceCts;
private readonly SemaphoreSlim _searchLock = new(1, 1);
// Action chooser / swap flow
private TireStack? actionCandidate;
private TireStack? swapOutgoing;
private List<TireStack> swapHistory = new();
private TireStack? swapSelectedHistory;
private bool swapUseHistory;
private TireStack swapNew = new();
private bool showLocationPicker;
private StorageLocation? swapTargetLocation;
private Guid? swapTargetLocationId => swapTargetLocation?.Id;
private SwapResult? swapResult;
private bool CanSwap => swapOutgoing != null && (swapUseHistory ? swapSelectedHistory != null : !string.IsNullOrWhiteSpace(swapNew.TireSize));
[SupplyParameterFromQuery(Name = "stackId")]
public Guid? PreselectedStackId { get; set; }
protected override async Task OnInitializedAsync()
{
if (!PreselectedStackId.HasValue || !UserSession.IsWorkerOrAdmin) return;
try
{
var stack = await TireStackService.GetByIdAsync(PreselectedStackId.Value);
if (stack == null)
{
errorMessage = "Der gewählte Reifenstapel wurde nicht gefunden.";
return;
}
searchResults = new List<TireStack> { stack };
OnResultClicked(stack);
}
catch (Exception ex)
{
errorMessage = $"Reifenstapel konnte nicht geladen werden: {ex.Message}";
}
}
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";
private void OnResultClicked(TireStack ts)
{
if (ts.Status != TireStackStatus.Stored)
{
errorMessage = $"'{ts.TicketNumber}' ist nicht eingelagert";
return;
}
// Inventory stacks and already selected stacks toggle directly
if (ts.IsInventory || scannedTireStacks.Any(s => s.Id == ts.Id))
{
AddToScannedList(ts);
return;
}
actionCandidate = ts;
errorMessage = null;
}
private void CloseActionChooser() => actionCandidate = null;
private void ChoosePickup()
{
if (actionCandidate == null) return;
AddToScannedList(actionCandidate);
actionCandidate = null;
}
private async Task ChooseSwap()
{
if (actionCandidate == null) return;
var ts = actionCandidate;
actionCandidate = null;
await StartSwap(ts);
}
private async Task StartSwap(TireStack outgoing)
{
swapOutgoing = outgoing;
swapResult = null;
showLocationPicker = false;
swapTargetLocation = outgoing.StorageLocation;
swapSelectedHistory = null;
errorMessage = null;
successMessage = null;
var oppositeSeason = outgoing.Season == TireSeason.Sommer ? TireSeason.Winter
: outgoing.Season == TireSeason.Winter ? TireSeason.Sommer
: TireSeason.Allrad;
swapNew = new TireStack
{
Season = oppositeSeason,
TireSize = outgoing.Vehicle?.TireSize ?? outgoing.TireSize,
WithRims = outgoing.WithRims,
Condition = TireCondition.Good,
CustomerId = outgoing.CustomerId,
VehicleId = outgoing.VehicleId
};
await _searchLock.WaitAsync();
try
{
var history = outgoing.CustomerId.HasValue
? await TireStackService.GetByCustomerAsync(outgoing.CustomerId.Value)
: new List<TireStack>();
swapHistory = history
.Where(h => h.Id != outgoing.Id && (h.Status == TireStackStatus.OnVehicle || h.Status == TireStackStatus.PickedUp))
.OrderBy(h => h.Season == outgoing.Season)
.ThenByDescending(h => h.PickedUpAt)
.ToList();
}
catch (Exception ex)
{
swapHistory = new();
errorMessage = $"Historie konnte nicht geladen werden: {ex.Message}";
}
finally
{
_searchLock.Release();
}
swapUseHistory = swapHistory.Count > 0;
swapSelectedHistory = swapHistory.FirstOrDefault(h => h.Season != outgoing.Season) ?? swapHistory.FirstOrDefault();
}
private void OnTargetLocationSelected(StorageLocation loc)
{
swapTargetLocation = loc;
}
private void ResetTargetLocation()
{
swapTargetLocation = swapOutgoing?.StorageLocation;
}
private void CancelSwap()
{
swapOutgoing = null;
swapHistory.Clear();
swapSelectedHistory = null;
showLocationPicker = false;
swapTargetLocation = null;
}
private async Task ProcessSwap()
{
if (swapOutgoing == null || !CanSwap) return;
isProcessing = true;
errorMessage = null;
StateHasChanged();
await _searchLock.WaitAsync();
try
{
var incoming = swapUseHistory
? new TireStack
{
Id = swapSelectedHistory!.Id,
Condition = swapSelectedHistory.Condition,
Notes = swapSelectedHistory.Notes,
VehicleId = swapOutgoing.VehicleId
}
: swapNew;
var targetLocation = swapTargetLocationId == swapOutgoing.StorageLocationId ? null : swapTargetLocationId;
swapResult = await TireStackService.SwapAsync(
swapOutgoing.Id,
incoming,
targetLocation,
UserSession.CurrentUser?.Id);
CancelSwap();
searchResults.Clear();
searchTerm = "";
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Reifenwechsel: {ex.Message}";
}
finally
{
_searchLock.Release();
isProcessing = false;
}
}
private void CloseSwapSuccess() => swapResult = null;
private async Task SearchTireStacks()
{
if (string.IsNullOrWhiteSpace(searchTerm) || searchTerm.Length < 2)
@@ -435,10 +856,11 @@ else
freedLocations.Clear();
}
public void Dispose()
public ValueTask DisposeAsync()
{
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
_searchLock.Dispose();
return ValueTask.CompletedTask;
}
}