Files
ReifenSpaeth/Lagerbestand/Components/Pages/TireCheckout.razor
T

867 lines
37 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/tire-checkout"
@using Lagerbestand.Components.Shared
@implements IAsyncDisposable
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject IStorageLocationService StorageLocationService
@inject UserSessionService UserSession
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Reifenstapel Abholen - Reifen Späth Lagerverwaltung"
Description="Reifen abholen in der Reifen Späth Lagerverwaltung. Schnelle Abwicklung von Kundenabholungen."
Robots="noindex, nofollow" />
@if (!UserSession.IsWorkerOrAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Mitarbeiter und Administratoren zugänglich.</p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
</section>
}
else
{
<main class="checkout-page" role="main">
<header class="page-header">
<h1>Reifenstapel Abholen</h1>
<p>Suchen Sie nach Ticket-Nummer, Kundenname, Fahrzeug oder Lagerplatz-Code</p>
</header>
@* Search Section *@
<section class="checkout-card" aria-labelledby="search-heading">
<h2 id="search-heading" class="visually-hidden">Suche</h2>
<div class="search-section">
<label for="search-input" class="visually-hidden">Suche nach Ticket-Nr., Kunde, Fahrzeug oder Lagerplatz</label>
<input type="search"
id="search-input"
class="form-control search-input"
placeholder="Ticket-Nr., Kunde, Fahrzeug oder Lagerplatz (z.B. 2135)..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="SearchTireStacks" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert" aria-live="assertive">@errorMessage</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success" role="status" aria-live="polite">@successMessage</div>
}
</section>
@* Scanned Items List *@
@if (scannedTireStacks.Count > 0)
{
<div class="scanned-section" aria-labelledby="scanned-items-heading">
<div class="scanned-header">
<h2 id="scanned-items-heading">
<span class="scan-count">@scannedTireStacks.Count</span>
Reifenstapel zum Abholen
</h2>
<button class="btn btn-sm btn-outline-danger" @onclick="ClearAllScanned">
Alle entfernen
</button>
</div>
<div class="scanned-summary" role="complementary">
<div class="summary-item">
<span class="summary-value">@scannedTireStacks.Count</span>
<span class="summary-label">Stapel</span>
</div>
<div class="summary-item">
<span class="summary-value">@(scannedTireStacks.Count * TireStack.TiresPerStack)</span>
<span class="summary-label">Reifen gesamt</span>
</div>
<div class="summary-item">
<span class="summary-value">@scannedTireStacks.Select(ts => ts.StorageLocation?.Code).Distinct().Count()</span>
<span class="summary-label">Lagerplätze</span>
</div>
</div>
<div class="scanned-list" role="list">
@foreach (var ts in scannedTireStacks)
{
<div class="scanned-item @(ts == lastAddedTireStack ? "highlight" : "")" role="listitem">
<div class="scanned-item-header">
<div class="scanned-item-main">
<span class="ticket-number">@ts.TicketNumber</span>
@if (ts.StorageLocation != null)
{
<LocationDisplay Code="@ts.StorageLocation.Code"
DisplayMode="LocationDisplayMode.Compact"
Size="LocationDisplaySize.Small" />
}
else
{
<span class="location-badge">📍 -</span>
}
</div>
<button class="btn-remove" @onclick="() => RemoveScannedItem(ts)" title="Entfernen" aria-label="Entfernen von @ts.TicketNumber">✕</button>
</div>
<div class="scanned-item-body">
<div class="scanned-customer">
@if (ts.Customer != null)
{
<span class="customer-name">@ts.Customer.FullName</span>
@if (ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
}
}
else if (ts.CatalogItem != null)
{
<span class="customer-name inventory">📦 @ts.CatalogItem.Name</span>
}
else
{
<span class="customer-name inventory">📦 Lagerbestand</span>
}
</div>
<div class="scanned-details">
<span class="tire-count">1 Stapel (@TireStack.TiresPerStack Reifen)</span>
<span class="tire-season">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</span>
@if (!string.IsNullOrEmpty(ts.TireSize))
{
<span class="tire-size">@ts.TireSize</span>
}
@if (ts.WithRims)
{
<span class="with-rims">mit Felgen</span>
}
</div>
@if (!string.IsNullOrEmpty(ts.TireBrand))
{
<div class="scanned-brand">
@ts.TireBrand @ts.TireModel
</div>
}
<div class="scanned-meta">
<span class="stored-date">Eingelagert: @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</span>
<span class="days-stored">(@ts.DaysStored Tage)</span>
</div>
</div>
</div>
}
</div>
<div class="scanned-actions">
<button class="btn btn-primary btn-lg w-100"
@onclick="ProcessBatchPickup"
disabled="@(isProcessing || scannedTireStacks.Count == 0)">
@if (isProcessing)
{
<span class="spinner"></span>
}
<span>@scannedTireStacks.Count Stapel abholen (@(scannedTireStacks.Count * TireStack.TiresPerStack) Reifen)</span>
</button>
</div>
</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)
{
<div class="results-section" aria-labelledby="search-results-heading">
<h2 id="search-results-heading" class="results-count">
@searchResults.Count Ergebnis(se)
</h2>
<div class="tireset-list" role="list">
@foreach (var ts in searchResults)
{
var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id);
<div class="tireset-card @(isAlreadyScanned ? "already-scanned" : "")"
@onclick="() => OnResultClicked(ts)" role="listitem">
<div class="tireset-header">
<span class="ticket-number">@ts.TicketNumber</span>
<span class="status-badge @ts.Status.ToString().ToLower()">
@if (isAlreadyScanned)
{
<span>✓ Ausgewählt</span>
}
else
{
@ts.StatusText
}
</span>
</div>
<div class="tireset-body">
<div class="customer-info">
@if (ts.Customer != null)
{
<span class="customer-name">@ts.Customer.FullName</span>
@if (ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
}
}
else
{
<span class="customer-name">📦 @(ts.CatalogItem?.Name ?? "Lagerbestand")</span>
}
</div>
<div class="tire-info">
<span class="tire-count">1 Stapel (@TireStack.TiresPerStack Reifen)</span>
<span class="tire-details">@ts.Season @(ts.TireSize != null ? $"| {ts.TireSize}" : "")</span>
</div>
<div class="location-info">
@if (ts.StorageLocation != null)
{
<LocationDisplay Code="@ts.StorageLocation.Code"
DisplayMode="LocationDisplayMode.Full"
Size="LocationDisplaySize.Small" />
}
else
{
<span class="location">📍 Kein Lagerplatz</span>
}
<span class="stored-date">Seit @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</span>
</div>
</div>
</div>
}
</div>
</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)
{
<div class="success-overlay" @onclick="CloseSuccess">
<div class="success-modal" @onclick:stopPropagation="true">
<div class="success-icon">✓</div>
<h2>Abholung erfolgreich!</h2>
@if (pickedUpTireStacks.Count == 1)
{
<p><strong>@pickedUpTireStacks[0].TicketNumber</strong> wurde abgeholt.</p>
<p class="pickup-detail">1 Stapel (@TireStack.TiresPerStack Reifen) abgeholt</p>
}
else
{
<p><strong>@pickedUpTireStacks.Count Reifenstapel</strong> wurden abgeholt.</p>
<p class="pickup-detail">@(pickedUpTireStacks.Count * TireStack.TiresPerStack) Reifen insgesamt</p>
<div class="pickup-list" role="list">
@foreach (var ts in pickedUpTireStacks)
{
<div class="pickup-item" role="listitem">
<span class="ticket">@ts.TicketNumber</span>
<span class="info">1 Stapel | @(ts.Customer?.FullName ?? ts.CatalogItem?.Name ?? "Lagerbestand")</span>
</div>
}
</div>
}
<p class="success-detail">
@if (freedLocations.Count == 1)
{
<span>Lagerplatz <strong>@freedLocations[0]</strong> ist wieder frei.</span>
}
else if (freedLocations.Count > 1)
{
<span><strong>@freedLocations.Count Lagerplätze</strong> sind wieder frei.</span>
}
</p>
<button class="btn btn-primary" @onclick="CloseSuccess">OK</button>
</div>
</div>
}
</main>
}
@code {
private bool isProcessing;
private bool showSuccess;
private string searchTerm = "";
private string? errorMessage;
private string? successMessage;
private List<TireStack> searchResults = new();
private List<TireStack> scannedTireStacks = new();
private List<TireStack> pickedUpTireStacks = new();
private List<string> freedLocations = new();
private TireStack? lastAddedTireStack;
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)
{
searchResults.Clear();
return;
}
// Debounce: cancel previous pending search, wait briefly for typing to settle
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
var cts = new CancellationTokenSource();
_searchDebounceCts = cts;
try
{
await Task.Delay(300, cts.Token);
}
catch (TaskCanceledException)
{
return;
}
// Serialize DB access: scoped DbContext must not be used concurrently
await _searchLock.WaitAsync();
try
{
if (cts.IsCancellationRequested) return;
searchResults = await TireStackService.SearchAsync(searchTerm);
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Suchfehler: {ex.Message}";
}
finally
{
_searchLock.Release();
}
}
private void AddToScannedList(TireStack tireStack)
{
if (tireStack.Status != TireStackStatus.Stored)
{
errorMessage = $"'{tireStack.TicketNumber}' ist nicht eingelagert";
return;
}
if (scannedTireStacks.Any(ts => ts.Id == tireStack.Id))
{
// Remove if already in list (toggle behavior)
scannedTireStacks.RemoveAll(ts => ts.Id == tireStack.Id);
if (lastAddedTireStack?.Id == tireStack.Id)
{
lastAddedTireStack = null;
}
}
else
{
scannedTireStacks.Add(tireStack);
lastAddedTireStack = tireStack;
}
errorMessage = null;
}
private void RemoveScannedItem(TireStack tireStack)
{
scannedTireStacks.Remove(tireStack);
if (lastAddedTireStack?.Id == tireStack.Id)
{
lastAddedTireStack = null;
}
}
private void ClearAllScanned()
{
scannedTireStacks.Clear();
lastAddedTireStack = null;
errorMessage = null;
successMessage = null;
}
private async Task ProcessBatchPickup()
{
if (scannedTireStacks.Count == 0) return;
isProcessing = true;
errorMessage = null;
StateHasChanged();
try
{
pickedUpTireStacks.Clear();
freedLocations.Clear();
foreach (var tireStack in scannedTireStacks)
{
var locationCode = tireStack.StorageLocation?.Code;
if (!string.IsNullOrEmpty(locationCode) && !freedLocations.Contains(locationCode))
{
freedLocations.Add(locationCode);
}
}
var pickedUp = await TireStackService.PickupBatchAsync(
scannedTireStacks.Select(ts => ts.Id),
UserSession.CurrentUser?.Id);
pickedUpTireStacks.AddRange(pickedUp);
showSuccess = true;
scannedTireStacks.Clear();
searchResults.Clear();
searchTerm = "";
lastAddedTireStack = null;
}
catch (Exception ex)
{
errorMessage = $"Fehler bei Abholung: {ex.Message}";
pickedUpTireStacks.Clear();
freedLocations.Clear();
}
finally
{
isProcessing = false;
}
}
private void CloseSuccess()
{
showSuccess = false;
pickedUpTireStacks.Clear();
freedLocations.Clear();
}
public ValueTask DisposeAsync()
{
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
_searchLock.Dispose();
return ValueTask.CompletedTask;
}
}