445 lines
17 KiB
Plaintext
445 lines
17 KiB
Plaintext
@page "/tire-checkout"
|
|
@using Lagerbestand.Components.Shared
|
|
@implements IDisposable
|
|
@inject ICustomerService CustomerService
|
|
@inject ITireStackService TireStackService
|
|
@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>
|
|
}
|
|
|
|
@* 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="() => AddToScannedList(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>
|
|
}
|
|
|
|
@* 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);
|
|
|
|
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 void Dispose()
|
|
{
|
|
_searchDebounceCts?.Cancel();
|
|
_searchDebounceCts?.Dispose();
|
|
_searchLock.Dispose();
|
|
}
|
|
}
|