Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

@@ -0,0 +1,444 @@
@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();
}
}