867 lines
37 KiB
Plaintext
867 lines
37 KiB
Plaintext
@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;
|
||
}
|
||
}
|