807 lines
38 KiB
Plaintext
807 lines
38 KiB
Plaintext
@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>
|
||
|
||
@* Compact Toolbar: Stats as clickable filter chips + controls *@
|
||
<section class="toolbar" aria-label="Statistik, Suche und Filter">
|
||
<div class="stat-chips" role="tablist" aria-label="Filter nach Typ">
|
||
<button class="stat-chip @(typeFilter == null ? "active" : "")"
|
||
@onclick="() => SetTypeFilter(null)"
|
||
role="tab" aria-selected="@(typeFilter == null)"
|
||
title="Alle Stapel anzeigen">
|
||
<span class="chip-value">@stats.TotalStacksStored</span> Stapel (@stats.TotalTiresStored Reifen)
|
||
</button>
|
||
<button class="stat-chip customer @(typeFilter == false ? "active" : "")"
|
||
@onclick="() => SetTypeFilter(typeFilter == false ? null : false)"
|
||
role="tab" aria-selected="@(typeFilter == false)"
|
||
title="Nur Kundenstapel anzeigen">
|
||
<span class="chip-value">@stats.CustomerStackCount</span> <span aria-hidden="true">👤</span> Kunden
|
||
</button>
|
||
<button class="stat-chip inventory @(typeFilter == true ? "active" : "")"
|
||
@onclick="() => SetTypeFilter(typeFilter == true ? null : true)"
|
||
role="tab" aria-selected="@(typeFilter == true)"
|
||
title="Nur Lagerbestand anzeigen">
|
||
<span class="chip-value">@stats.InventoryStackCount</span> <span aria-hidden="true">📦</span> Lager
|
||
</button>
|
||
<span class="stat-chip static" title="Freie Lagerplätze">
|
||
<span class="chip-value free">@stats.AvailableLocations</span> Frei
|
||
</span>
|
||
</div>
|
||
|
||
<div class="toolbar-controls">
|
||
<div class="search-box">
|
||
<label for="search-input" class="visually-hidden">Suche</label>
|
||
<input type="search"
|
||
id="search-input"
|
||
class="search-input"
|
||
placeholder="🔍 Suchen (Name, Fahrzeug, 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>
|
||
</div>
|
||
|
||
<fieldset class="filter-buttons">
|
||
<legend class="visually-hidden">Nach Saison filtern</legend>
|
||
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
|
||
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Sommer ? null : TireSeason.Sommer)"
|
||
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
|
||
aria-label="Sommerreifen" title="Sommerreifen">
|
||
<span aria-hidden="true">☀️</span>
|
||
</button>
|
||
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
|
||
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Winter ? null : TireSeason.Winter)"
|
||
aria-pressed="@(seasonFilter == TireSeason.Winter)"
|
||
aria-label="Winterreifen" title="Winterreifen">
|
||
<span aria-hidden="true">❄️</span>
|
||
</button>
|
||
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
|
||
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Allrad ? null : TireSeason.Allrad)"
|
||
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
|
||
aria-label="Ganzjahresreifen" title="Ganzjahresreifen">
|
||
<span aria-hidden="true">🔄</span>
|
||
</button>
|
||
</fieldset>
|
||
|
||
@if (typeFilter == true && categories.Count > 0)
|
||
{
|
||
<select id="category-select" class="form-control category-select" @bind="selectedCategoryId" @bind:after="FilterTireStacks" title="Kategorie auswählen">
|
||
<option value="">Alle Kategorien</option>
|
||
@foreach (var cat in categories)
|
||
{
|
||
<option value="@cat.Id">@cat.Name</option>
|
||
}
|
||
</select>
|
||
}
|
||
|
||
<div class="view-toggle" role="tablist" aria-label="Ansicht wählen">
|
||
<button class="view-btn @(showGridView ? "" : "active")"
|
||
@onclick="() => showGridView = false"
|
||
role="tab" aria-selected="@(!showGridView)" title="Listenansicht">
|
||
<span aria-hidden="true">☰</span> Liste
|
||
</button>
|
||
<button class="view-btn @(showGridView ? "active" : "")"
|
||
@onclick="() => showGridView = true"
|
||
role="tab" aria-selected="@showGridView" title="Regalansicht">
|
||
<span aria-hidden="true">▦</span> Regal
|
||
</button>
|
||
</div>
|
||
|
||
@if (HasActiveFilters)
|
||
{
|
||
<button class="btn btn-outline btn-sm clear-filters" @onclick="ResetFilters" title="Filter zurücksetzen">
|
||
✕ Filter löschen
|
||
</button>
|
||
}
|
||
</div>
|
||
</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;
|
||
@StackDetailLists(stack)
|
||
@if (!string.IsNullOrEmpty(stack.Notes))
|
||
{
|
||
<div class="notes-section">
|
||
<span class="label">Notizen:</span>
|
||
<span class="notes-text">@stack.Notes</span>
|
||
</div>
|
||
}
|
||
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
|
||
<button type="button" class="btn btn-sm btn-primary" @onclick="() => OpenEditModalFor(stack)">✏️ Bearbeiten</button>
|
||
@if (stack.CustomerId.HasValue)
|
||
{
|
||
<a href="@CheckinUrl(stack)" class="btn btn-sm btn-outline">+ Einlagern</a>
|
||
}
|
||
<a href="@CheckoutUrl(stack)" class="btn btn-sm btn-outline">@(stack.IsInventory ? "Entnehmen" : "Abholen")</a>
|
||
</nav>
|
||
}
|
||
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
|
||
{
|
||
@if (selectedStack != null)
|
||
{
|
||
<aside class="stack-details-panel" aria-label="Details zum gewählten Reifenstapel">
|
||
<header class="stack-details-header">
|
||
<div class="stack-details-title">
|
||
@if (selectedStack.IsInventory)
|
||
{
|
||
<h2><span aria-hidden="true">📦</span> @(selectedStack.CatalogItem?.Name ?? "Lagerbestand")</h2>
|
||
}
|
||
else
|
||
{
|
||
<h2>@selectedStack.Customer?.FullName</h2>
|
||
@if (selectedStack.Vehicle != null)
|
||
{
|
||
<span class="vehicle-badge">@selectedStack.Vehicle.Make @selectedStack.Vehicle.Model</span>
|
||
}
|
||
}
|
||
</div>
|
||
<div class="stack-details-actions">
|
||
<button type="button" class="btn btn-sm btn-primary" @onclick="OpenEditModal">✏️ Bearbeiten</button>
|
||
<button type="button" class="btn btn-sm btn-outline" @onclick="() => selectedStack = null">Schließen</button>
|
||
</div>
|
||
</header>
|
||
@StackDetailLists(selectedStack)
|
||
@if (!string.IsNullOrEmpty(selectedStack.Notes))
|
||
{
|
||
<div class="notes-section">
|
||
<span class="label">Notizen:</span>
|
||
<span class="notes-text">@selectedStack.Notes</span>
|
||
</div>
|
||
}
|
||
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
|
||
@if (selectedStack.CustomerId.HasValue)
|
||
{
|
||
<a href="@CheckinUrl(selectedStack)" class="btn btn-sm btn-outline">+ Einlagern</a>
|
||
}
|
||
<a href="@CheckoutUrl(selectedStack)" class="btn btn-sm btn-primary">@(selectedStack.IsInventory ? "Entnehmen" : "Abholen")</a>
|
||
</nav>
|
||
</aside>
|
||
}
|
||
|
||
<section class="tireset-list" aria-label="Liste der Reifenstapel" role="list">
|
||
@foreach (var ts in filteredTireStacks)
|
||
{
|
||
<article class="tireset-card @(ts.IsInventory ? "inventory" : "") @(selectedStack?.Id == ts.Id ? "selected" : "")"
|
||
@onclick="() => SelectStack(ts)"
|
||
role="listitem"
|
||
aria-selected="@(selectedStack?.Id == ts.Id)">
|
||
<div class="card-row card-row-primary">
|
||
<div class="card-identity">
|
||
@if (ts.IsInventory)
|
||
{
|
||
<span class="customer-name inventory-name">
|
||
<span aria-hidden="true">📦</span> @(ts.CatalogItem?.Name ?? "Lagerbestand")
|
||
</span>
|
||
<span class="inventory-badge">Lager</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="customer-name">@ts.Customer?.FullName</span>
|
||
@if (ts.Vehicle != null)
|
||
{
|
||
<span class="vehicle-badge">@ts.Vehicle.Make @ts.Vehicle.Model</span>
|
||
}
|
||
}
|
||
</div>
|
||
<div class="card-location">
|
||
@if (ts.StorageLocation != null)
|
||
{
|
||
<span class="location-indicator">
|
||
<span class="loc-area @(ts.StorageLocation.Area == AreaType.Werkstatt ? "werkstatt" : "lager")">@ts.StorageLocation.Area.GetDisplayName()</span>
|
||
<span class="loc-part">Regal @ts.StorageLocation.Row</span>
|
||
<span class="loc-part">@ts.StorageLocation.Position.GetShortName()</span>
|
||
<span class="loc-part">P@(ts.StorageLocation.SlotNumber)</span>
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="location-code">-</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="card-row card-row-secondary">
|
||
<span 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 ? "❄️" : "🔄")
|
||
</span>
|
||
<span class="tire-details">@ts.ItemCount Reifen @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.TireModel != null ? $"| {ts.TireModel}" : "") @(ts.WithRims ? "| mit Felgen" : "")</span>
|
||
</div>
|
||
</article>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
@if (showEditModal && editStack != null)
|
||
{
|
||
<div class="modal-overlay" @onclick="CloseEditModal">
|
||
<div class="modal-dialog" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
|
||
<header class="modal-header">
|
||
<h2 id="edit-modal-title">Reifenstapel bearbeiten – @editStack.TicketNumber</h2>
|
||
<button type="button" class="modal-close" @onclick="CloseEditModal" aria-label="Schließen">✕</button>
|
||
</header>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="edit-size">Reifengröße</label>
|
||
<input id="edit-size" class="form-control" @bind="editStack.TireSize" placeholder="z.B. 225/45R17" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="edit-brand">Marke</label>
|
||
<input id="edit-brand" class="form-control" @bind="editStack.TireBrand" placeholder="z.B. Michelin" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="edit-model">Modell</label>
|
||
<input id="edit-model" class="form-control" @bind="editStack.TireModel" placeholder="z.B. Pilot Sport 4" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="edit-count">Anzahl</label>
|
||
<input id="edit-count" type="number" min="1" max="8" class="form-control" @bind="editStack.ItemCount" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="edit-season">Saison</label>
|
||
<select id="edit-season" class="form-control" @bind="editStack.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="edit-condition">Zustand</label>
|
||
<select id="edit-condition" class="form-control" @bind="editStack.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">
|
||
<label for="edit-rims">Felgen</label>
|
||
<select id="edit-rims" class="form-control" @bind="editStack.WithRims">
|
||
<option value="true">Mit Felgen</option>
|
||
<option value="false">Ohne Felgen</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group form-group-wide">
|
||
<label for="edit-notes">Notizen</label>
|
||
<textarea id="edit-notes" class="form-control" rows="3" @bind="editStack.Notes"></textarea>
|
||
</div>
|
||
</div>
|
||
<TireInspectionPanel TireStackId="@editStack.Id" AllowEdit="true" Compact="true"
|
||
ScannerSubtitle="@editStack.TicketNumber" />
|
||
@if (!string.IsNullOrEmpty(editError))
|
||
{
|
||
<p class="edit-error" role="alert">@editError</p>
|
||
}
|
||
</div>
|
||
<footer class="modal-footer">
|
||
<button type="button" class="btn btn-outline" @onclick="CloseEditModal" disabled="@isSaving">Abbrechen</button>
|
||
<button type="button" class="btn btn-primary" @onclick="SaveEdit" disabled="@isSaving">
|
||
@(isSaving ? "Speichern..." : "Speichern")
|
||
</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* 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 static string CheckinUrl(TireStack stack) => $"/tire-checkin?customerId={stack.CustomerId}";
|
||
private static string CheckoutUrl(TireStack stack) => $"/tire-checkout?stackId={stack.Id}";
|
||
|
||
private RenderFragment<TireStack> StackDetailLists => stack => __builder =>
|
||
{
|
||
<div class="detail-columns">
|
||
<section class="detail-list" aria-labelledby="detail-list-customer">
|
||
<h3 id="detail-list-customer" class="detail-list-title"><span aria-hidden="true">👤</span> @(stack.IsInventory ? "Bestand & Lagerplatz" : "Kunde & Lagerplatz")</h3>
|
||
<dl class="detail-rows">
|
||
<div class="detail-row">
|
||
<dt>Ticket-Nr.</dt>
|
||
<dd class="mono">@stack.TicketNumber</dd>
|
||
</div>
|
||
@if (stack.IsInventory)
|
||
{
|
||
<div class="detail-row">
|
||
<dt>Kategorie</dt>
|
||
<dd>@(stack.CatalogItem?.Name ?? "Lagerbestand")</dd>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="detail-row">
|
||
<dt>Kunde</dt>
|
||
<dd>@(stack.Customer?.FullName ?? "-")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Kundennr.</dt>
|
||
<dd class="mono">@(stack.Customer?.CustomerNumber ?? "-")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Telefon</dt>
|
||
<dd>@(stack.Customer?.Phone ?? "-")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Fahrzeug</dt>
|
||
<dd>@(stack.Vehicle != null ? $"{stack.Vehicle.Make} {stack.Vehicle.Model}".Trim() : "-")</dd>
|
||
</div>
|
||
}
|
||
<div class="detail-row">
|
||
<dt>Lagerplatz</dt>
|
||
<dd>
|
||
@if (stack.StorageLocation != null)
|
||
{
|
||
<span class="location-indicator">
|
||
<span class="loc-area @(stack.StorageLocation.Area == AreaType.Werkstatt ? "werkstatt" : "lager")">@stack.StorageLocation.Area.GetDisplayName()</span>
|
||
<span class="loc-part">Regal @stack.StorageLocation.Row</span>
|
||
<span class="loc-part">@stack.StorageLocation.Position.GetShortName()</span>
|
||
<span class="loc-part">P@(stack.StorageLocation.SlotNumber)</span>
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span>-</span>
|
||
}
|
||
</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Code</dt>
|
||
<dd class="mono">@(stack.StorageLocation?.Code ?? "-")</dd>
|
||
</div>
|
||
</dl>
|
||
</section>
|
||
|
||
<section class="detail-list" aria-labelledby="detail-list-tires">
|
||
<h3 id="detail-list-tires" class="detail-list-title"><span aria-hidden="true">🛞</span> Reifen & Lagerung</h3>
|
||
<dl class="detail-rows">
|
||
<div class="detail-row">
|
||
<dt>Anzahl</dt>
|
||
<dd>@stack.ItemCount Reifen</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Größe</dt>
|
||
<dd class="mono">@(stack.TireSize ?? "-")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Marke / Modell</dt>
|
||
<dd>@(string.Join(" ", new[] { stack.TireBrand, stack.TireModel }.Where(s => !string.IsNullOrWhiteSpace(s))) is { Length: > 0 } bm ? bm : "-")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Saison</dt>
|
||
<dd>@(stack.Season == TireSeason.Sommer ? "☀️ Sommer" : stack.Season == TireSeason.Winter ? "❄️ Winter" : "🔄 Allrad")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Felgen</dt>
|
||
<dd>@(stack.WithRims ? "Mit Felgen" : "Ohne Felgen")</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Zustand</dt>
|
||
<dd>@stack.ConditionText</dd>
|
||
</div>
|
||
<div class="detail-row">
|
||
<dt>Eingelagert</dt>
|
||
<dd><time datetime="@stack.StoredAt.ToString("yyyy-MM-dd")">@stack.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</time> <span class="muted">(@stack.DaysStored Tage)</span></dd>
|
||
</div>
|
||
@if (stack.ExpectedPickupDate.HasValue)
|
||
{
|
||
<div class="detail-row">
|
||
<dt>Abholung erwartet</dt>
|
||
<dd><time datetime="@stack.ExpectedPickupDate.Value.ToString("yyyy-MM-dd")">@stack.ExpectedPickupDate.Value.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
|
||
</div>
|
||
}
|
||
</dl>
|
||
</section>
|
||
</div>
|
||
<TireInspectionPanel TireStackId="@stack.Id" AllowEdit="@UserSession.IsWorkerOrAdmin" Compact="true"
|
||
ScannerSubtitle="@(stack.Customer != null ? $"Kunde: {stack.Customer.FullName}" : stack.TicketNumber)" />
|
||
};
|
||
|
||
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 TireStack? selectedStack;
|
||
private TireStack? editStack;
|
||
private TireStack? editSource;
|
||
private bool showEditModal;
|
||
private bool isSaving;
|
||
private string? editError;
|
||
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 bool HasActiveFilters =>
|
||
typeFilter != null || seasonFilter != null ||
|
||
!string.IsNullOrEmpty(selectedCategoryId) || !string.IsNullOrWhiteSpace(searchTerm);
|
||
|
||
private void ResetFilters()
|
||
{
|
||
typeFilter = null;
|
||
seasonFilter = null;
|
||
selectedCategoryId = "";
|
||
searchTerm = "";
|
||
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 SelectStack(TireStack ts)
|
||
{
|
||
selectedStack = selectedStack?.Id == ts.Id ? null : ts;
|
||
}
|
||
|
||
private void OpenEditModal()
|
||
{
|
||
if (selectedStack == null) return;
|
||
OpenEditModalFor(selectedStack);
|
||
}
|
||
|
||
private void OpenEditModalFor(TireStack source)
|
||
{
|
||
editSource = source;
|
||
editStack = new TireStack
|
||
{
|
||
Id = source.Id,
|
||
TireSize = source.TireSize,
|
||
TireBrand = source.TireBrand,
|
||
TireModel = source.TireModel,
|
||
ItemCount = source.ItemCount,
|
||
Season = source.Season,
|
||
Condition = source.Condition,
|
||
WithRims = source.WithRims,
|
||
Notes = source.Notes,
|
||
TicketNumber = source.TicketNumber
|
||
};
|
||
editError = null;
|
||
showEditModal = true;
|
||
}
|
||
|
||
private void CloseEditModal()
|
||
{
|
||
if (isSaving) return;
|
||
showEditModal = false;
|
||
editStack = null;
|
||
editSource = null;
|
||
editError = null;
|
||
}
|
||
|
||
private async Task SaveEdit()
|
||
{
|
||
if (editStack == null || editSource == null) return;
|
||
isSaving = true;
|
||
editError = null;
|
||
try
|
||
{
|
||
editSource.TireSize = editStack.TireSize;
|
||
editSource.TireBrand = editStack.TireBrand;
|
||
editSource.TireModel = editStack.TireModel;
|
||
editSource.ItemCount = editStack.ItemCount;
|
||
editSource.Season = editStack.Season;
|
||
editSource.Condition = editStack.Condition;
|
||
editSource.WithRims = editStack.WithRims;
|
||
editSource.Notes = editStack.Notes;
|
||
|
||
await TireStackService.UpdateAsync(editSource);
|
||
|
||
showEditModal = false;
|
||
editStack = null;
|
||
editSource = null;
|
||
var selectedId = selectedStack?.Id;
|
||
await LoadData();
|
||
if (selectedId.HasValue)
|
||
{
|
||
selectedStack = allTireStacks.FirstOrDefault(ts => ts.Id == selectedId.Value);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
editError = $"Fehler beim Speichern: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
isSaving = false;
|
||
}
|
||
}
|
||
|
||
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";
|
||
}
|
||
}
|