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,564 @@
@page "/tire-storage"
@implements IAsyncDisposable
@inject ITireStackService TireStackService
@inject IStorageLocationService StorageLocationService
@inject ICatalogService CatalogService
@inject UserSessionService UserSession
@inject IJSRuntime JS
@using Lagerbestand.Components.Shared
<PageTitle>Lagerübersicht - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Lagerübersicht - Reifen Späth Lagerverwaltung"
Description="Übersicht aller eingelagerten Reifenstapel in der Reifen Späth Lagerverwaltung."
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
{
<div class="storage-page">
<header class="page-header">
<div class="header-content">
<h1>Lagerübersicht</h1>
<nav class="header-actions" aria-label="Schnellaktionen">
<a href="/tire-checkin" class="btn btn-primary">
<span aria-hidden="true">+</span> Einlagern
</a>
<a href="/tire-checkout" class="btn btn-outline">
Abholen
</a>
</nav>
</div>
</header>
@* Statistics *@
<section class="stats-grid" aria-label="Lagerstatistik">
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.TotalStacksStored</span>
<span class="stat-label">Stapel (@stats.TotalTiresStored Reifen)</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.CustomerStackCount</span>
<span class="stat-label"><span aria-hidden="true">👤</span> Kundenstapel</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.InventoryStackCount</span>
<span class="stat-label"><span aria-hidden="true">📦</span> Lagerbestand</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.AvailableLocations</span>
<span class="stat-label">Freie Plätze</span>
</article>
</section>
@* Type Filter (Customer vs Inventory) *@
<nav class="type-filter" role="tablist" aria-label="Filter nach Typ">
<button class="type-btn @(typeFilter == null ? "active" : "")"
@onclick="() => SetTypeFilter(null)"
role="tab"
aria-selected="@(typeFilter == null)">
Alle
</button>
<button class="type-btn @(typeFilter == false ? "active" : "")"
@onclick="() => SetTypeFilter(false)"
role="tab"
aria-selected="@(typeFilter == false)">
<span aria-hidden="true">👤</span> Kundenstapel
</button>
<button class="type-btn @(typeFilter == true ? "active" : "")"
@onclick="() => SetTypeFilter(true)"
role="tab"
aria-selected="@(typeFilter == true)">
<span aria-hidden="true">📦</span> Lagerbestand
</button>
</nav>
@* View Toggle (List / Rack Grid) *@
<nav class="view-toggle" role="tablist" aria-label="Ansicht wählen">
<button class="view-btn @(showGridView ? "" : "active")"
@onclick="() => showGridView = false"
role="tab"
aria-selected="@(!showGridView)">
<span aria-hidden="true">☰</span> Liste
</button>
<button class="view-btn @(showGridView ? "active" : "")"
@onclick="() => showGridView = true"
role="tab"
aria-selected="@showGridView">
<span aria-hidden="true">▦</span> Regalansicht
</button>
</nav>
@* Search & Filter *@
<section class="filter-bar" aria-label="Suche und Filter">
<label for="search-input" class="visually-hidden">Suche</label>
<input type="search"
id="search-input"
class="search-input"
placeholder="Suchen (Name, Fahrzeug, Kategorie, Ticket-Nr.)..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="FilterTireStacks"
aria-describedby="search-hint" />
<span id="search-hint" class="visually-hidden">Durchsucht Namen, Fahrzeuge, Kategorien und Ticket-Nummern</span>
<fieldset class="filter-buttons">
<legend class="visually-hidden">Nach Saison filtern</legend>
<button class="filter-btn @(seasonFilter == null ? "active" : "")"
@onclick="() => SetSeasonFilter(null)"
aria-pressed="@(seasonFilter == null)">
Alle
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Sommer)"
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
aria-label="Sommerreifen">
<span aria-hidden="true">☀️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Winter)"
aria-pressed="@(seasonFilter == TireSeason.Winter)"
aria-label="Winterreifen">
<span aria-hidden="true">❄️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Allrad)"
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
aria-label="Ganzjahresreifen">
<span aria-hidden="true">🔄</span>
</button>
</fieldset>
</section>
@* Category Filter (only when showing inventory) *@
@if (typeFilter == true && categories.Count > 0)
{
<section class="category-filter">
<label for="category-select" class="visually-hidden">Kategorie auswählen</label>
<select id="category-select" class="form-control" @bind="selectedCategoryId" @bind:after="FilterTireStacks">
<option value="">-- Alle Kategorien --</option>
@foreach (var cat in categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</select>
</section>
}
@* Tire Stacks List *@
@if (isLoading)
{
<div class="loading-state" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<span>Lade Daten...</span>
</div>
}
else if (showGridView)
{
<section class="rack-view" aria-label="Regalansicht der Lagerplätze">
@foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
{
<div class="building-section" @key="area">
<h2 class="building-title">@area.GetBuildingLabel()</h2>
@for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
{
var currentRow = row;
<div class="rack-row" @key="@($"{area}-{currentRow}")">
<h3 class="row-title">Reihe @currentRow</h3>
<div class="rack-sides">
@foreach (var side in new[] { "Links", "Rechts" })
{
<div class="rack-side" @key="side">
<span class="side-label">@side</span>
@foreach (var pos in ShelfPositionExtensions.All.Where(p => p.IsLeft() == (side == "Links")).OrderByDescending(p => p.IsTop()))
{
<div class="rack-level" @key="pos">
<span class="level-label">@(pos.IsTop() ? "Oben" : "Unten")</span>
<div class="rack-cells">
@for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
var loc = GetLocation(area, currentRow, pos, slot);
var cellClass = loc == null ? "missing" : !loc.IsAvailable ? "blocked" : loc.IsOccupied ? "occupied" : "free";
<button type="button"
class="rack-cell @cellClass @(selectedLocation?.Id == loc?.Id ? "selected" : "")"
@key="slot"
disabled="@(loc == null)"
title="@GetCellTooltip(loc, area, currentRow, pos, slot)"
@onclick="() => SelectLocation(loc)">
@slot
</button>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
</div>
}
<div class="rack-legend" aria-hidden="true">
<span class="legend-item"><span class="legend-swatch free"></span> Frei</span>
<span class="legend-item"><span class="legend-swatch occupied"></span> Belegt</span>
<span class="legend-item"><span class="legend-swatch blocked"></span> Gesperrt</span>
</div>
@if (selectedLocation != null)
{
<aside class="cell-details" aria-label="Details zum gewählten Lagerplatz">
<header class="cell-details-header">
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" IsOccupied="@selectedLocation.IsOccupied" ShowStatus="true" />
<button type="button" class="btn btn-sm btn-outline" @onclick="() => selectedLocation = null">Schließen</button>
</header>
@if (selectedLocation.IsOccupied && selectedLocation.OccupiedTireStack != null)
{
var stack = selectedLocation.OccupiedTireStack;
<dl class="detail-grid">
<div class="detail-item">
<dt class="label">Ticket-Nr.</dt>
<dd class="value">@stack.TicketNumber</dd>
</div>
<div class="detail-item">
<dt class="label">@(stack.IsInventory ? "Kategorie" : "Kunde")</dt>
<dd class="value">@(stack.IsInventory ? stack.CatalogItem?.Name ?? "Lagerbestand" : stack.Customer?.FullName ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Reifen</dt>
<dd class="value">@(stack.TireSize ?? "-") @(stack.WithRims ? "| mit Felgen" : "")</dd>
</div>
<div class="detail-item">
<dt class="label">Eingelagert</dt>
<dd class="value">@stack.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</dd>
</div>
</dl>
}
else if (!selectedLocation.IsAvailable)
{
<p class="cell-details-text">Dieser Lagerplatz ist gesperrt. @selectedLocation.Notes</p>
}
else
{
<p class="cell-details-text">Dieser Lagerplatz ist frei.</p>
<a href="/tire-checkin" class="btn btn-sm btn-primary">Einlagern</a>
}
</aside>
}
</section>
}
else if (filteredTireStacks.Count == 0)
{
<section class="empty-state" aria-labelledby="empty-title">
<div class="empty-icon" aria-hidden="true">📦</div>
<h2 id="empty-title">Keine Einträge</h2>
<p>@(string.IsNullOrEmpty(searchTerm) ? "Noch keine Reifenstapel eingelagert." : "Keine Ergebnisse für Ihre Suche.")</p>
@if (string.IsNullOrEmpty(searchTerm))
{
<a href="/tire-checkin" class="btn btn-primary">Reifenstapel einlagern</a>
}
</section>
}
else
{
<section class="tireset-list" aria-label="Liste der Reifenstapel" role="list">
@foreach (var ts in filteredTireStacks)
{
<article class="tireset-card @(ts.IsInventory ? "inventory" : "")"
@onclick="() => ToggleDetails(ts.Id)"
role="listitem"
aria-expanded="@(expandedId == ts.Id)">
<div class="tireset-main">
<div class="tireset-left">
<div class="season-indicator @ts.Season.ToString().ToLower()"
aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</div>
<div class="tireset-info">
@if (ts.IsInventory)
{
<span class="customer-name inventory-name">
<span aria-hidden="true">📦</span> @(ts.CatalogItem?.Name ?? "Lagerbestand")
</span>
}
else
{
<span class="customer-name">@ts.Customer?.FullName</span>
}
<span class="tire-details">1 Stapel (@TireStack.TiresPerStack Reifen) @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.WithRims ? "| mit Felgen" : "")</span>
</div>
</div>
<div class="tireset-right">
<span class="location-code">@(ts.StorageLocation?.Code ?? "-")</span>
@if (!ts.IsInventory && ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
}
@if (ts.IsInventory)
{
<span class="inventory-badge">Lager</span>
}
</div>
</div>
@if (expandedId == ts.Id)
{
<div class="tireset-details">
<dl class="detail-grid">
<div class="detail-item">
<dt class="label">Ticket-Nr.</dt>
<dd class="value">@ts.TicketNumber</dd>
</div>
<div class="detail-item">
<dt class="label">Reifenstapel</dt>
<dd class="value">1 Stapel (@TireStack.TiresPerStack Reifen)</dd>
</div>
@if (ts.IsInventory && ts.CatalogItem != null)
{
<div class="detail-item">
<dt class="label">Kategorie</dt>
<dd class="value">@ts.CatalogItem.Name</dd>
</div>
}
<div class="detail-item">
<dt class="label">Marke</dt>
<dd class="value">@(ts.TireBrand ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Modell</dt>
<dd class="value">@(ts.TireModel ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Zustand</dt>
<dd class="value">@ts.ConditionText</dd>
</div>
<div class="detail-item">
<dt class="label">Eingelagert</dt>
<dd class="value"><time datetime="@ts.StoredAt.ToString("yyyy-MM-dd")">@ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
</div>
<div class="detail-item">
<dt class="label">Dauer</dt>
<dd class="value">@ts.DaysStored Tage</dd>
</div>
</dl>
@if (!string.IsNullOrEmpty(ts.Notes))
{
<div class="notes-section">
<span class="label">Notizen:</span>
<span class="notes-text">@ts.Notes</span>
</div>
}
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
@if (!ts.IsInventory)
{
<a href="/tire-checkout" class="btn btn-sm btn-primary">Abholen</a>
}
else
{
<a href="/tire-checkout" class="btn btn-sm btn-primary">Entnehmen</a>
}
</nav>
</div>
}
</article>
}
</section>
}
@* Expiring Pickups Warning (only for customer stacks) *@
@if (expiringPickups.Count > 0)
{
<aside class="expiring-section" aria-labelledby="expiring-title" role="alert">
<h2 id="expiring-title"><span aria-hidden="true">⚠️</span> Bevorstehende Abholungen</h2>
<ul class="expiring-list">
@foreach (var ts in expiringPickups)
{
<li class="expiring-item">
<span class="customer">@ts.Customer?.FullName</span>
<span class="date">Erwartet: <time datetime="@ts.ExpectedPickupDate?.ToString("yyyy-MM-dd")">@ts.ExpectedPickupDate?.ToLocalTime().ToString("dd.MM.yyyy")</time></span>
</li>
}
</ul>
</aside>
}
</div>
}
@code {
private List<TireStack> allTireStacks = new();
private List<TireStack> filteredTireStacks = new();
private List<TireStack> expiringPickups = new();
private List<CatalogItem> categories = new();
private StorageStatistics stats = new();
private string searchTerm = "";
private TireSeason? seasonFilter;
private bool? typeFilter; // null = all, false = customer, true = inventory
private string selectedCategoryId = "";
private Guid? expandedId;
private bool isLoading = true;
private bool showGridView;
private List<StorageLocation> allLocations = new();
private Dictionary<(AreaType, int, ShelfPosition, int), StorageLocation> locationLookup = new();
private StorageLocation? selectedLocation;
protected override async Task OnInitializedAsync()
{
if (UserSession.IsWorkerOrAdmin)
{
await LoadData();
}
}
private DotNetObjectReference<TireStorage>? _visibilityRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsWorkerOrAdmin)
{
_visibilityRef = DotNetObjectReference.Create(this);
await VisibilityRefresh.RegisterAsync(JS, _visibilityRef, nameof(OnVisibilityReturn), 120);
}
}
[JSInvokable]
public async Task OnVisibilityReturn(int hiddenSeconds)
{
if (!UserSession.IsWorkerOrAdmin) return;
await LoadData();
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
await VisibilityRefresh.UnregisterAsync(JS);
_visibilityRef?.Dispose();
}
private async Task LoadData()
{
isLoading = true;
StateHasChanged();
try
{
allTireStacks = await TireStackService.GetAllAsync(TireStackStatus.Stored);
stats = await TireStackService.GetStatisticsAsync();
expiringPickups = await TireStackService.GetExpiringPickupsAsync();
categories = await CatalogService.GetAllAsync();
allLocations = await StorageLocationService.GetAllAsync();
locationLookup = allLocations.ToDictionary(l => (l.Area, l.Row, l.Position, l.SlotNumber));
if (selectedLocation != null)
{
selectedLocation = allLocations.FirstOrDefault(l => l.Id == selectedLocation.Id);
}
ApplyFilters();
}
finally
{
isLoading = false;
}
}
private void FilterTireStacks()
{
ApplyFilters();
}
private void SetSeasonFilter(TireSeason? season)
{
seasonFilter = season;
ApplyFilters();
}
private void SetTypeFilter(bool? isInventory)
{
typeFilter = isInventory;
selectedCategoryId = ""; // Reset category filter when changing type
ApplyFilters();
}
private void ApplyFilters()
{
var query = allTireStacks.AsEnumerable();
// Type filter (customer vs inventory)
if (typeFilter.HasValue)
{
if (typeFilter.Value)
query = query.Where(ts => ts.CustomerId == null);
else
query = query.Where(ts => ts.CustomerId != null);
}
// Season filter
if (seasonFilter.HasValue)
{
query = query.Where(ts => ts.Season == seasonFilter.Value);
}
// Category filter (for inventory)
if (!string.IsNullOrEmpty(selectedCategoryId) && Guid.TryParse(selectedCategoryId, out var catId))
{
query = query.Where(ts => ts.CatalogItemId == catId);
}
// Search term
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower();
query = query.Where(ts =>
(ts.Customer?.FullName?.ToLower().Contains(term) ?? false) ||
(ts.Vehicle?.Make?.ToLower().Contains(term) ?? false) ||
(ts.Vehicle?.Model?.ToLower().Contains(term) ?? false) ||
(ts.Customer?.CustomerNumber?.ToLower().Contains(term) ?? false) ||
(ts.CatalogItem?.Name?.ToLower().Contains(term) ?? false) ||
(ts.StorageLocation?.Code?.ToLower().Contains(term) ?? false) ||
ts.TicketNumber.ToLower().Contains(term) ||
(ts.TireBrand?.ToLower().Contains(term) ?? false) ||
(ts.TireSize?.ToLower().Contains(term) ?? false)
);
}
filteredTireStacks = query.OrderBy(ts => ts.StorageLocation?.Code).ToList();
}
private void ToggleDetails(Guid id)
{
expandedId = expandedId == id ? null : id;
}
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
{
return locationLookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
}
private void SelectLocation(StorageLocation? location)
{
if (location == null) return;
selectedLocation = selectedLocation?.Id == location.Id ? null : location;
}
private string GetCellTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot)
{
var baseText = $"{area.GetDisplayName()}, Reihe {row}, {pos.GetDisplayName()}, Platz {slot}";
if (loc == null) return $"{baseText} – nicht angelegt";
if (!loc.IsAvailable) return $"{baseText} – gesperrt";
if (loc.IsOccupied)
{
var stack = loc.OccupiedTireStack;
var who = stack == null ? "belegt" : stack.IsInventory ? stack.CatalogItem?.Name ?? "Lagerbestand" : stack.Customer?.FullName ?? "belegt";
return $"{baseText} – {who}";
}
return $"{baseText} – frei";
}
}