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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user