Files

2203 lines
92 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/tire-checkin"
@implements IAsyncDisposable
@using Lagerbestand.Components.Shared
@inject ICustomerService CustomerService
@inject IStorageLocationService StorageLocationService
@inject ILocationCodeService LocationCodeService
@inject ITireStackService TireStackService
@inject ITireReportService TireReportService
@inject ICatalogService CatalogService
@inject IFileStorageService FileStorageService
@inject UserSessionService UserSession
@inject IJSRuntime JS
<PageTitle>Reifenstapel Einlagern - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Reifenstapel Einlagern - Reifen Späth Lagerverwaltung"
Description="Reifen einlagern in der Reifen Späth Lagerverwaltung. Professionelle Einlagerung von Kundenreifen und Lagerbestand."
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="checkin-page" role="main">
@* Progress Steps *@
<nav class="progress-steps" aria-label="Einlagerungsschritte">
<div class="step @(currentStep >= 1 ? "active" : "") @(currentStep > 1 ? "completed" : "")" aria-current="@(currentStep == 1 ? "step" : null)">
<span class="step-number" aria-hidden="true">1</span>
<span class="step-label">@(isInventoryMode ? "Kategorie" : "Kunde")</span>
</div>
<div class="step-line @(currentStep > 1 ? "completed" : "")" aria-hidden="true"></div>
<div class="step @(currentStep >= 2 ? "active" : "") @(currentStep > 2 ? "completed" : "")" aria-current="@(currentStep == 2 ? "step" : null)">
<span class="step-number" aria-hidden="true">2</span>
<span class="step-label">Reifen</span>
</div>
<div class="step-line @(currentStep > 2 ? "completed" : "")" aria-hidden="true"></div>
<div class="step @(currentStep >= 3 ? "active" : "") @(currentStep > 3 ? "completed" : "")" aria-current="@(currentStep == 3 ? "step" : null)">
<span class="step-number" aria-hidden="true">3</span>
<span class="step-label">Lagerplatz</span>
</div>
<div class="step-line @(currentStep > 3 ? "completed" : "")" aria-hidden="true"></div>
<div class="step @(currentStep >= 4 ? "active" : "")" aria-current="@(currentStep == 4 ? "step" : null)">
<span class="step-number" aria-hidden="true">4</span>
<span class="step-label">Fertig</span>
</div>
</nav>
@* Step 1: Customer OR Category Selection *@
@if (currentStep == 1)
{
<div class="checkin-card">
@* Mode Toggle *@
<div class="mode-toggle">
<button class="mode-btn @(!isInventoryMode ? "active" : "")" @onclick="() => SetMode(false)">
👤 Kundenreifen
</button>
<button class="mode-btn @(isInventoryMode ? "active" : "")" @onclick="() => SetMode(true)">
📦 Lagerbestand (Neuware)
</button>
</div>
@if (!isInventoryMode)
{
@* Customer Selection Mode *@
<h2>Kunde auswählen</h2>
<div class="search-box">
<input type="text"
class="form-control"
placeholder="Name, Fahrzeug oder Kundennummer..."
@bind="customerSearch"
@bind:event="oninput"
@onkeyup="SearchCustomers" />
</div>
@if (isSearching)
{
<div class="loading-indicator">
<div class="spinner"></div>
<span>Suche...</span>
</div>
}
else if (searchResults.Count > 0)
{
<div class="customer-list">
@foreach (var customer in searchResults)
{
<div class="customer-item @(selectedCustomer?.Id == customer.Id ? "selected" : "")"
@onclick="() => SelectCustomer(customer)">
<div class="customer-info">
<span class="customer-name">@customer.FullName</span>
@if (!string.IsNullOrEmpty(customer.Company))
{
<span class="customer-company">@customer.Company</span>
}
<span class="customer-details">
@customer.CustomerNumber
@if (customer.Vehicles.Any(v => v.IsActive))
{
<span class="vehicle-make">@string.Join(", ", customer.Vehicles.Where(v => v.IsActive).Select(v => v.ShortName))</span>
}
</span>
</div>
<div class="customer-badge">
@customer.TireStacks.Count(ts => ts.Status == TireStackStatus.Stored) Stapel
</div>
</div>
}
</div>
}
else if (!string.IsNullOrEmpty(customerSearch))
{
<div class="no-results">
<p>Kein Kunde gefunden</p>
<button class="btn btn-outline" @onclick="ShowNewCustomerForm">
+ Neuen Kunden anlegen
</button>
</div>
}
@if (showNewCustomerForm)
{
<div class="new-customer-form">
<h3>Neuer Kunde</h3>
<div class="form-row">
<div class="form-group">
<label>Vorname</label>
<input type="text" class="form-control" @bind="newCustomer.FirstName" />
</div>
<div class="form-group">
<label>Nachname *</label>
<input type="text" class="form-control" @bind="newCustomer.LastName" />
</div>
</div>
<div class="form-group">
<label>Firma</label>
<input type="text" class="form-control" @bind="newCustomer.Company" />
</div>
<div class="form-row">
<div class="form-group">
<label>Telefon</label>
<input type="tel" class="form-control" @bind="newCustomer.Phone" />
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" class="form-control" @bind="newCustomer.Email" />
</div>
</div>
<h4 class="form-section-title">Fahrzeug</h4>
<div class="form-row">
<div class="form-group">
<label>Marke *</label>
<input type="text" class="form-control" @bind="newVehicle.Make" placeholder="z.B. BMW" />
</div>
<div class="form-group">
<label>Modell</label>
<input type="text" class="form-control" @bind="newVehicle.Model" placeholder="z.B. 3er" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Reifengröße *</label>
<TireSizeInput @bind-Value="newVehicle.TireSize" />
</div>
</div>
<div class="form-actions">
<button class="btn btn-outline" @onclick="CancelNewCustomer">Abbrechen</button>
<button class="btn btn-primary" @onclick="CreateNewCustomer" disabled="@(!CanCreateCustomer)">
Kunde anlegen
</button>
</div>
</div>
}
@* Vehicle Selection for existing customer *@
@if (selectedCustomer != null && !showNewCustomerForm)
{
<div class="vehicle-selection-section">
<h3>Fahrzeug auswählen</h3>
@if (customerVehicles.Count == 0)
{
<div class="no-vehicles-info">
<p>Noch kein Fahrzeug hinterlegt.</p>
</div>
}
else
{
<div class="vehicle-list">
@foreach (var vehicle in customerVehicles)
{
<div class="vehicle-item @(selectedVehicle?.Id == vehicle.Id ? "selected" : "")"
@onclick="() => SelectVehicle(vehicle)">
<div class="vehicle-info">
<span class="vehicle-name">@vehicle.DisplayName</span>
@if (vehicle.IsPrimary)
{
<span class="primary-badge">Hauptfahrzeug</span>
}
</div>
<div class="vehicle-meta">
@(vehicle.TireStacks?.Count(ts => ts.Status == TireStackStatus.Stored) ?? 0) Stapel eingelagert
</div>
</div>
}
</div>
}
@* Add new vehicle button *@
<button class="btn btn-outline btn-sm mt-3" @onclick="ShowNewVehicleForm">
+ Neues Fahrzeug hinzufügen
</button>
@if (showAddVehicleForm)
{
<div class="new-vehicle-form mt-3">
<h4>Neues Fahrzeug</h4>
<div class="form-row">
<div class="form-group">
<label>Marke *</label>
<input type="text" class="form-control" @bind="newVehicle.Make" placeholder="z.B. BMW" />
</div>
<div class="form-group">
<label>Modell</label>
<input type="text" class="form-control" @bind="newVehicle.Model" placeholder="z.B. 3er" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Reifengröße *</label>
<TireSizeInput @bind-Value="newVehicle.TireSize" />
</div>
</div>
<div class="form-actions">
<button class="btn btn-outline btn-sm" @onclick="CancelNewVehicle">Abbrechen</button>
<button class="btn btn-primary btn-sm" @onclick="AddNewVehicle" disabled="@(!CanCreateVehicle)">
Fahrzeug hinzufügen
</button>
</div>
</div>
}
</div>
}
<div class="step-actions">
<button class="btn btn-primary btn-lg"
@onclick="GoToStep2"
disabled="@(selectedCustomer == null || (customerVehicles.Count > 0 && selectedVehicle == null))">
Weiter zu Reifendaten
</button>
</div>
}
else
{
@* Inventory/Category Selection Mode *@
<h2>Kategorie auswählen</h2>
<p class="mode-description">Für Neuware/Lieferungen die ins Lager aufgenommen werden sollen.</p>
@if (categories.Count == 0)
{
<div class="no-results">
<p>Keine Kategorien vorhanden</p>
<button class="btn btn-outline" @onclick="ShowNewCategoryForm">
+ Neue Kategorie anlegen
</button>
</div>
}
else
{
<div class="category-list">
@foreach (var category in categories)
{
<div class="category-item @(selectedCategory?.Id == category.Id ? "selected" : "")"
@onclick="() => SelectCategory(category)">
@if (category.Type == CatalogItemType.Felgen && !string.IsNullOrEmpty(category.PictureFileName))
{
<div class="category-thumbnail" @onclick:stopPropagation="true" @onclick="() => ShowImageZoom(category)">
<img src="@GetCategoryImageUrl(category)" alt="@category.Name" loading="lazy" />
</div>
}
else
{
<div class="category-icon">
@if (category.Type == CatalogItemType.Felgen)
{
<span>🛞</span>
}
else
{
@(category.Season == TireSeason.Sommer ? "☀️" : category.Season == TireSeason.Winter ? "❄️" : "🔄")
}
</div>
}
<div class="category-info">
<span class="category-name">@category.Name</span>
<span class="category-details">@category.DisplayDetails</span>
<span class="category-type-badge @(category.Type == CatalogItemType.Felgen ? "felgen" : "reifen")">
@(category.Type == CatalogItemType.Felgen ? "Felgen" : "Reifen")
</span>
</div>
</div>
}
</div>
<button class="btn btn-outline btn-sm mt-3" @onclick="ShowNewCategoryForm">
+ Neue Kategorie anlegen
</button>
}
@if (showNewCategoryForm)
{
<div class="new-category-form">
<h3>Neue Kategorie</h3>
@* Category Type Toggle *@
<div class="form-group">
<label>Kategorie-Typ *</label>
<div class="category-type-toggle">
<button class="type-btn @(newCategory.Type == CatalogItemType.Reifen ? "active reifen" : "")"
@onclick="() => SetNewCategoryType(CatalogItemType.Reifen)">
🛞 Reifen
</button>
<button class="type-btn @(newCategory.Type == CatalogItemType.Felgen ? "active felgen" : "")"
@onclick="() => SetNewCategoryType(CatalogItemType.Felgen)">
🔩 Felgen
</button>
</div>
</div>
<div class="form-group">
<label>Name *</label>
<input type="text" class="form-control" @bind="newCategory.Name"
placeholder="@(newCategory.Type == CatalogItemType.Felgen ? "z.B. BBS CH-R 8.5x19" : "z.B. Michelin Pilot Sport 4")" />
</div>
<div class="form-group">
<label>@(newCategory.Type == CatalogItemType.Felgen ? "Felgengröße" : "Reifengröße") *</label>
@if (newCategory.Type == CatalogItemType.Felgen)
{
<input type="text" class="form-control" @bind="newCategory.TireSize" placeholder="z.B. 17 Zoll" />
}
else
{
<TireSizeInput @bind-Value="newCategory.TireSize" />
}
</div>
@if (newCategory.Type == CatalogItemType.Reifen)
{
<div class="form-group">
<label>Saison *</label>
<div class="season-selector">
<button class="season-btn @(newCategory.Season == TireSeason.Sommer ? "active summer" : "")"
@onclick="() => newCategory.Season = TireSeason.Sommer">
☀️ Sommer
</button>
<button class="season-btn @(newCategory.Season == TireSeason.Winter ? "active winter" : "")"
@onclick="() => newCategory.Season = TireSeason.Winter">
❄️ Winter
</button>
<button class="season-btn @(newCategory.Season == TireSeason.Allrad ? "active allseason" : "")"
@onclick="() => newCategory.Season = TireSeason.Allrad">
🔄 Ganzjahr
</button>
</div>
</div>
}
@if (newCategory.Type == CatalogItemType.Felgen)
{
<div class="form-group">
<label>Bild (optional)</label>
<div class="file-upload-area">
@if (categoryImagePreview != null)
{
<div class="image-preview">
<img src="@categoryImagePreview" alt="Vorschau" />
<button class="btn-clear image-remove" @onclick="RemoveCategoryImage">✕</button>
</div>
}
else
{
<label class="upload-label">
<InputFile OnChange="OnCategoryImageSelected" accept="image/*" />
<span class="upload-icon">📷</span>
<span class="upload-text">Bild auswählen oder hierher ziehen</span>
</label>
}
</div>
</div>
}
<div class="form-actions">
<button class="btn btn-outline" @onclick="CancelNewCategory">Abbrechen</button>
<button class="btn btn-primary" @onclick="CreateNewCategory" disabled="@(!CanCreateCategory)">
Kategorie anlegen
</button>
</div>
</div>
}
<div class="step-actions">
<button class="btn btn-primary btn-lg"
@onclick="GoToStep2"
disabled="@(selectedCategory == null)">
Weiter zu Reifendaten
</button>
</div>
}
</div>
}
@* Step 2: Tire Stack Information *@
@if (currentStep == 2)
{
<div class="checkin-card">
<div class="selected-customer-bar">
@if (isInventoryMode)
{
<span>Kategorie: <strong>@selectedCategory?.Name</strong></span>
}
else
{
<span>Kunde: <strong>@selectedCustomer?.FullName</strong></span>
@if (selectedVehicle != null)
{
<span class="vehicle-badge">🚗 @selectedVehicle.DisplayName</span>
}
}
<button class="btn-link" @onclick="() => GoToStep(1)">Ändern</button>
</div>
<h2>@(isFelgenCategory ? "Felgen erfassen" : "Reifenstapel erfassen")</h2>
<div class="stack-info-banner">
<span class="stack-icon">@(isFelgenCategory ? "🔩" : "🛞")</span>
@if (isInventoryMode)
{
<span class="stack-text">
Stückzahl:
@if (useCustomItemCount)
{
<strong>@tireStack.ItemCount</strong>
}
else
{
<strong>@TireStack.TiresPerStack Stück (Standard)</strong>
}
</span>
<button class="btn-link stack-count-toggle" @onclick="ToggleCustomItemCount">
@(useCustomItemCount ? "Standard (4)" : "Anzahl ändern")
</button>
}
else
{
<span class="stack-text">Ein Stapel = <strong>@TireStack.TiresPerStack Reifen</strong></span>
}
</div>
@if (isInventoryMode && useCustomItemCount)
{
<div class="item-count-input">
<label>Stückzahl</label>
<div class="count-controls">
<button class="count-btn" @onclick="() => AdjustItemCount(-1)" disabled="@(tireStack.ItemCount <= 1)">−</button>
<input type="number" class="form-control count-field" min="1" max="99" @bind="tireStack.ItemCount" />
<button class="count-btn" @onclick="() => AdjustItemCount(1)" disabled="@(tireStack.ItemCount >= 99)">+</button>
</div>
</div>
}
@if (!isInventoryMode)
{
@* QR report scan *@
<div class="report-scan-bar">
@if (pendingReport == null)
{
<div class="report-scan-text">
<span class="report-scan-icon" aria-hidden="true">📷</span>
<span>Profiltiefen-Report (Launch TTS01) per QR-Code übernehmen</span>
</div>
<button type="button" class="btn btn-outline" @onclick="OpenQrScanner" disabled="@isLoadingReport">
@if (isLoadingReport)
{
<span class="spinner"></span>
<span>Report wird geladen…</span>
}
else
{
<span>QR-Code scannen</span>
}
</button>
}
else
{
<div class="report-attached">
<div class="report-attached-head">
<span class="report-scan-icon" aria-hidden="true">📋</span>
<strong>Report wird mit dem neuen Stapel gespeichert</strong>
<button type="button" class="btn-link" @onclick="RemovePendingReport">Entfernen</button>
</div>
@ReportSummaryFragment(pendingReport)
</div>
}
</div>
}
@* Tire/Felgen Form *@
<div class="tire-form">
@if (!isFelgenCategory)
{
<div class="form-group">
<label>Saison *</label>
<div class="season-selector">
<button class="season-btn @(tireStack.Season == TireSeason.Sommer ? "active summer" : "")"
@onclick="() => tireStack.Season = TireSeason.Sommer">
☀️ Sommer
</button>
<button class="season-btn @(tireStack.Season == TireSeason.Winter ? "active winter" : "")"
@onclick="() => tireStack.Season = TireSeason.Winter">
❄️ Winter
</button>
<button class="season-btn @(tireStack.Season == TireSeason.Allrad ? "active allseason" : "")"
@onclick="() => tireStack.Season = TireSeason.Allrad">
🔄 Ganzjahr
</button>
</div>
</div>
}
<div class="form-group">
<label>@(isFelgenCategory ? "Felgengröße" : "Reifengröße")</label>
@if (isFelgenCategory)
{
<input type="text" class="form-control" @bind="tireStack.TireSize" placeholder="z.B. 17 Zoll" />
}
else
{
<TireSizeInput Value="@tireStack.TireSize" ValueChanged="OnTireSizeChanged" />
@if (tireSizeFromVehicle && !string.IsNullOrEmpty(tireStack.TireSize))
{
<span class="field-hint">🚗 Vom Fahrzeug übernommen</span>
}
}
</div>
<div class="form-row">
<div class="form-group">
<label>@(isFelgenCategory ? "Hersteller" : "Marke")</label>
<input type="text" class="form-control" @bind="tireStack.TireBrand"
placeholder="@(isFelgenCategory ? "z.B. BBS, OZ Racing" : "z.B. Michelin, Continental")" />
</div>
<div class="form-group">
<label>@(isFelgenCategory ? "Felgenmodell" : "Modell")</label>
<input type="text" class="form-control" @bind="tireStack.TireModel"
placeholder="@(isFelgenCategory ? "z.B. CH-R, Superturismo" : "z.B. Pilot Sport 4")" />
</div>
</div>
@if (!isFelgenCategory)
{
<div class="form-row">
<div class="form-group">
<label>Mit Felgen?</label>
<div class="toggle-group">
<button class="toggle-btn @(tireStack.WithRims ? "active" : "")"
@onclick="() => tireStack.WithRims = true">
Ja
</button>
<button class="toggle-btn @(!tireStack.WithRims ? "active" : "")"
@onclick="() => tireStack.WithRims = false">
Nein
</button>
</div>
</div>
@if (tireStack.WithRims)
{
<div class="form-group">
<label>Felgengröße</label>
<input type="text" class="form-control" @bind="tireStack.RimSize"
placeholder="z.B. 17 Zoll" />
</div>
}
</div>
}
else
{
<div class="form-group">
<label>Felgentyp</label>
<div class="toggle-group">
<button class="toggle-btn @(tireStack.RimSize == "Alu" ? "active" : "")"
@onclick="() => SetRimType(true)">
Alu
</button>
<button class="toggle-btn @(tireStack.RimSize == "Stahl" ? "active" : "")"
@onclick="() => SetRimType(false)">
Stahl
</button>
</div>
</div>
}
@* Collapsible Details Section *@
<div class="details-accordion">
<button class="accordion-header" @onclick="ToggleDetails">
<span>Weitere Details (optional)</span>
<span class="accordion-icon">@(showDetailsExpanded ? "−" : "+")</span>
</button>
@if (showDetailsExpanded)
{
<div class="accordion-content">
@if (!isFelgenCategory)
{
<div class="form-row">
<div class="form-group">
<label>Profiltiefe (mm)</label>
<input type="number" class="form-control" step="0.5" min="0" max="10"
@bind="tireStack.TreadDepth" placeholder="z.B. 5.5" />
</div>
<div class="form-group">
<label>DOT-Code</label>
<input type="text" class="form-control" @bind="tireStack.DotCode"
placeholder="z.B. 2523" maxlength="20" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>RDKS/TPMS?</label>
<div class="toggle-group">
<button class="toggle-btn @(tireStack.HasTpms ? "active" : "")"
@onclick="() => tireStack.HasTpms = true">
Ja
</button>
<button class="toggle-btn @(!tireStack.HasTpms ? "active" : "")"
@onclick="() => tireStack.HasTpms = false">
Nein
</button>
</div>
</div>
<div class="form-group">
<label>Zustand</label>
<select class="form-control" @bind="tireStack.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>
}
else
{
<div class="form-group">
<label>Zustand</label>
<select class="form-control" @bind="tireStack.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>Notizen</label>
<textarea class="form-control" rows="2" @bind="tireStack.Notes"
placeholder="Besondere Hinweise, Beschädigungen, etc."></textarea>
</div>
</div>
}
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="step-actions">
<button class="btn btn-outline" @onclick="GoBackToStep1">Zurück</button>
<button class="btn btn-primary btn-lg" @onclick="GoToStep3">
Weiter zu Lagerplatz (@(isInventoryMode && useCustomItemCount ? tireStack.ItemCount : TireStack.TiresPerStack) @(isFelgenCategory ? "Felgen" : "Reifen"))
</button>
</div>
</div>
}
@* Step 3: Storage Location *@
@if (currentStep == 3)
{
<div class="checkin-card wide">
<div class="selected-info-bar">
@if (isInventoryMode)
{
<div>Kategorie: <strong>@selectedCategory?.Name</strong></div>
}
else
{
<div>Kunde: <strong>@selectedCustomer?.FullName</strong></div>
@if (selectedVehicle != null)
{
<div>Fahrzeug: <strong>@selectedVehicle.DisplayName</strong></div>
}
}
<div>@(isFelgenCategory ? "Felgen" : "Reifenstapel"): <strong>@(isInventoryMode && useCustomItemCount ? tireStack.ItemCount : TireStack.TiresPerStack) @(isFelgenCategory ? "Felgen" : "Reifen") @(isFelgenCategory ? "" : $"| {tireStack.Season}")</strong></div>
</div>
<h2>Lagerplatz auswählen</h2>
@if (selectedLocation != null)
{
<div class="checkin-summary">
<h3 class="summary-title">📋 Zusammenfassung</h3>
<div class="summary-grid">
@if (isInventoryMode)
{
<div class="summary-item">
<span class="summary-label">Kategorie</span>
<span class="summary-value">📦 @selectedCategory?.Name</span>
</div>
}
else
{
<div class="summary-item">
<span class="summary-label">Kunde</span>
<span class="summary-value">👤 @selectedCustomer?.FullName</span>
</div>
@if (selectedVehicle != null)
{
<div class="summary-item">
<span class="summary-label">Fahrzeug</span>
<span class="summary-value">🚗 @selectedVehicle.DisplayName</span>
</div>
}
}
<div class="summary-item">
<span class="summary-label">@(isFelgenCategory ? "Felgen" : "Reifen")</span>
<span class="summary-value">
@(isInventoryMode && useCustomItemCount ? tireStack.ItemCount : TireStack.TiresPerStack) Stück
@if (!isFelgenCategory)
{
<text> @(tireStack.Season == TireSeason.Sommer ? "☀️" : tireStack.Season == TireSeason.Winter ? "❄️" : "🔄") @tireStack.Season</text>
}
</span>
</div>
@if (!string.IsNullOrEmpty(tireStack.TireSize))
{
<div class="summary-item">
<span class="summary-label">Größe</span>
<span class="summary-value">@tireStack.TireSize @(tireStack.WithRims && !isFelgenCategory ? "| mit Felgen" : "")</span>
</div>
}
@if (!string.IsNullOrEmpty(tireStack.TireBrand) || !string.IsNullOrEmpty(tireStack.TireModel))
{
<div class="summary-item">
<span class="summary-label">Marke</span>
<span class="summary-value">@tireStack.TireBrand @tireStack.TireModel</span>
</div>
}
<div class="summary-item highlight">
<span class="summary-label">Lagerplatz</span>
<span class="summary-value location">
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" ShowIcon="true" Size="LocationDisplaySize.Small" />
</span>
</div>
</div>
<button class="btn-clear summary-clear" @onclick="ClearSelectedLocation" title="Lagerplatz-Auswahl aufheben">✕</button>
</div>
}
@if (locationSelectorCollapsed && selectedLocation != null)
{
<button type="button" class="location-collapsed-bar" @onclick="ExpandLocationSelector" title="Lagerplatz ändern">
<span class="collapsed-icon">📍</span>
<span class="collapsed-code">@selectedLocation.Code</span>
<span class="collapsed-hint">Lagerplatz ändern ▾</span>
</button>
}
else
{
<div class="location-selector compact">
@* Area & Row Filter combined *@
<div class="location-filter-row">
<div class="area-filter">
<button class="filter-btn @(selectedAreaFilter == null ? "active" : "")"
@onclick="() => FilterLocationsByArea(null)">
Alle
</button>
<button class="filter-btn lager @(selectedAreaFilter == AreaType.Lager ? "active" : "")"
@onclick="() => FilterLocationsByArea(AreaType.Lager)">
🏭 Lager
</button>
<button class="filter-btn werkstatt @(selectedAreaFilter == AreaType.Werkstatt ? "active" : "")"
@onclick="() => FilterLocationsByArea(AreaType.Werkstatt)">
🔧 Werkstatt
</button>
</div>
<div class="row-filter">
<select class="form-control" @bind="selectedRowFilter" @bind:after="ApplyLocationFilter">
<option value="">Alle Reihen</option>
@for (int r = StorageLocation.MinRow; r <= StorageLocation.MaxRowLager; r++)
{
<option value="@r">Reihe @r</option>
}
</select>
</div>
</div>
@* Location Grid *@
@if (filteredLocations.Count == 0)
{
<div class="no-locations">
<p>Keine freien Lagerplätze verfügbar</p>
<a href="/storage-locations" class="btn btn-outline">Lagerplätze verwalten</a>
</div>
}
else
{
@foreach (var areaGroup in filteredLocations.GroupBy(l => l.Area).OrderBy(g => (int)g.Key))
{
<div class="area-section">
<h3 class="area-title">
@(areaGroup.Key == AreaType.Lager ? "🏭" : "🔧") @areaGroup.Key.GetDisplayName()
</h3>
@foreach (var rowGroup in areaGroup.GroupBy(l => l.Row).OrderBy(g => g.Key))
{
<div class="row-section">
<span class="row-label">Reihe @rowGroup.Key</span>
<div class="location-grid">
@foreach (var loc in rowGroup.OrderBy(l => l.Position).ThenBy(l => l.SlotNumber))
{
<button class="location-tile @(selectedLocation?.Id == loc.Id ? "selected" : "") @(loc.IsOccupied ? "occupied" : "available")"
@onclick="() => SelectLocation(loc)"
disabled="@(loc.IsOccupied || !loc.IsAvailable)">
<span class="tile-code">@loc.Code</span>
<span class="tile-slot">@loc.Position.GetShortName() P@(loc.SlotNumber)</span>
</button>
}
</div>
</div>
}
</div>
}
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="step-actions">
<button class="btn btn-outline" @onclick="GoBackToStep2">Zurück</button>
<button class="btn btn-primary btn-lg"
@onclick="CompleteCheckin"
disabled="@(selectedLocation == null || isProcessing)">
@if (isProcessing)
{
<span class="spinner"></span>
}
Einlagern abschließen
</button>
</div>
</div>
}
@* Step 4: Confirmation *@
@if (currentStep == 4)
{
<div class="checkin-card success-card">
<div class="success-icon">✓</div>
<h2>Erfolgreich eingelagert!</h2>
<div class="ticket-preview">
<div class="ticket-header">
<span class="ticket-number">@createdTireStack?.TicketNumber</span>
@if (isInventoryMode)
{
<span class="inventory-badge">Lagerbestand</span>
}
</div>
<div class="ticket-details">
@if (isInventoryMode)
{
<div class="detail-row">
<span class="label">Kategorie:</span>
<span class="value">@selectedCategory?.Name</span>
</div>
}
else
{
<div class="detail-row">
<span class="label">Kunde:</span>
<span class="value">@selectedCustomer?.FullName</span>
</div>
<div class="detail-row">
<span class="label">Fahrzeug:</span>
<span class="value">@(selectedVehicle?.DisplayName ?? "-")</span>
</div>
}
<div class="detail-row">
<span class="label">@(isFelgenCategory ? "Felgen:" : "Reifenstapel:")</span>
<span class="value">@(createdTireStack?.ItemCount ?? TireStack.TiresPerStack) @(isFelgenCategory ? "Felgen" : "Reifen")</span>
</div>
<div class="detail-row">
<span class="label">Saison:</span>
<span class="value">@createdTireStack?.Season</span>
</div>
@if (!string.IsNullOrEmpty(createdTireStack?.TireSize))
{
<div class="detail-row">
<span class="label">Größe:</span>
<span class="value">@createdTireStack.TireSize</span>
</div>
}
@if (!string.IsNullOrEmpty(createdTireStack?.TireBrand))
{
<div class="detail-row">
<span class="label">Marke:</span>
<span class="value">@createdTireStack.TireBrand @createdTireStack.TireModel</span>
</div>
}
<div class="detail-row">
<span class="label">Lagerplatz:</span>
<span class="value">
@if (selectedLocation != null)
{
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" Size="LocationDisplaySize.Small" />
}
</span>
</div>
<div class="detail-row">
<span class="label">Eingelagert am:</span>
<span class="value">@createdTireStack?.StoredAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</span>
</div>
</div>
</div>
@if (createdTireStack != null && !isInventoryMode)
{
<TireInspectionPanel TireStackId="@createdTireStack.Id" AllowEdit="true" InitiallyOpen="@(pendingReportSaved)"
ScannerSubtitle="@(selectedCustomer != null ? $"Kunde: {selectedCustomer.FullName}" : null)" />
}
<div class="step-actions">
<button class="btn btn-outline" @onclick="ResetForm">
@(isInventoryMode ? "Weiteren Stapel einlagern" : "Neuen Reifenstapel einlagern")
</button>
<a href="/tire-storage" class="btn btn-primary">
Zur Übersicht
</a>
</div>
</div>
}
</main>
@* Image Zoom Overlay *@
@if (showImageZoom && zoomImageUrl != null)
{
<div class="image-zoom-overlay" @onclick="CloseImageZoom">
<div class="image-zoom-container" @onclick:stopPropagation="true">
<div class="zoom-header">
<span class="zoom-title">@zoomImageTitle</span>
<button class="btn-clear zoom-close" @onclick="CloseImageZoom">✕</button>
</div>
<div class="zoom-image-wrapper">
<img src="@zoomImageUrl" alt="@zoomImageTitle" class="zoom-image" />
</div>
</div>
</div>
}
<QrScannerModal @bind-IsOpen="showQrScanner"
Title="Reifen-Report scannen"
Subtitle="@(selectedCustomer != null ? $"Kunde: {selectedCustomer.FullName}" : null)"
OnScanned="OnQrScanned" />
@* Report preview / assignment modal *@
@if (scannedReport != null)
{
<div class="report-overlay" @onclick="CloseReportModal">
<div class="report-dialog" role="dialog" aria-modal="true" aria-labelledby="report-title" @onclick:stopPropagation="true">
<div class="report-header">
<div>
<h3 id="report-title">📋 Reifen-Report</h3>
<p class="report-subtitle">
Nr. <span class="mono">@scannedReport.RecordSn</span>
@if (scannedReport.MeasuredAt.HasValue)
{
<text> · gemessen am @scannedReport.MeasuredAt.Value.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</text>
}
</p>
</div>
<button type="button" class="report-close" @onclick="CloseReportModal" aria-label="Schließen">✕</button>
</div>
<div class="report-body">
@if (!scannedReport.HasTreadData)
{
<div class="report-warning">
⚠️ Der Report enthält keine Profiltiefen-Werte. Werte können unten manuell ergänzt werden.
</div>
}
<div class="report-meta">
<div class="form-group">
<label>Kennzeichen</label>
<input type="text" class="form-control" @bind="scannedReport.LicensePlate" placeholder="z.B. ND-XX 123" />
</div>
<div class="form-group">
<label>Kilometerstand</label>
<input type="number" class="form-control" @bind="scannedReport.Mileage" min="0" placeholder="km" />
</div>
</div>
<div class="tread-grid" role="group" aria-label="Profiltiefen">
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthFL)">
<label>Vorne links</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthFL" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthFR)">
<label>Vorne rechts</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthFR" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthRL)">
<label>Hinten links</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthRL" /><span>mm</span></div>
</div>
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthRR)">
<label>Hinten rechts</label>
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthRR" /><span>mm</span></div>
</div>
</div>
@if (!string.IsNullOrEmpty(scannedReport.Recommendation))
{
<div class="report-recommendation">💡 @scannedReport.Recommendation</div>
}
<a class="report-link" href="@scannedReport.ReportUrl" target="_blank" rel="noopener">Original-Report öffnen ↗</a>
<div class="report-choice">
<h4>Wie sollen die Daten verwendet werden?</h4>
<div class="report-choice-buttons">
<button type="button" class="choice-btn @(reportMode == ReportAssignMode.Existing ? "active" : "")"
@onclick="() => SetReportMode(ReportAssignMode.Existing)"
disabled="@(customerStacksForReport.Count == 0)">
<span class="choice-icon">📦</span>
<span class="choice-title">Zu vorhandenem Satz</span>
<span class="choice-sub">@(customerStacksForReport.Count == 0 ? "Keine Sätze vorhanden" : $"{customerStacksForReport.Count} Satz/Sätze des Kunden")</span>
</button>
<button type="button" class="choice-btn @(reportMode == ReportAssignMode.New ? "active" : "")"
@onclick="() => SetReportMode(ReportAssignMode.New)">
<span class="choice-icon">✨</span>
<span class="choice-title">Neuen Satz anlegen</span>
<span class="choice-sub">Daten ins Formular übernehmen</span>
</button>
</div>
@if (reportMode == ReportAssignMode.Existing)
{
<div class="stack-pick-list" role="list">
@foreach (var s in customerStacksForReport)
{
<button type="button" class="stack-pick-item @(reportTargetStackId == s.Id ? "selected" : "")" role="listitem"
@onclick="() => reportTargetStackId = s.Id">
<span class="stack-pick-main">
<span class="mono">@s.TicketNumber</span>
<span>@(s.Season == TireSeason.Sommer ? "☀️" : s.Season == TireSeason.Winter ? "❄️" : "🔄") @s.Season</span>
@if (!string.IsNullOrEmpty(s.TireSize)) { <span>@s.TireSize</span> }
</span>
<span class="stack-pick-sub">
@s.StatusText
@if (s.Vehicle != null) { <text> · 🚗 @s.Vehicle.ShortName</text> }
@if (s.StorageLocation != null) { <text> · @s.StorageLocation.Code</text> }
</span>
</button>
}
</div>
}
</div>
@if (!string.IsNullOrEmpty(reportError))
{
<div class="report-warning">⚠️ @reportError</div>
}
</div>
<div class="report-footer">
<button type="button" class="btn btn-outline" @onclick="CloseReportModal">Abbrechen</button>
@if (reportMode == ReportAssignMode.Existing)
{
<button type="button" class="btn btn-primary" @onclick="AttachReportToExisting" disabled="@(reportTargetStackId == null || isProcessing)">
@if (isProcessing) { <span class="spinner"></span> }
<span>Zum Satz hinzufügen</span>
</button>
}
else
{
<button type="button" class="btn btn-primary" @onclick="ApplyReportToNew">
<span>Für neuen Satz übernehmen</span>
</button>
}
</div>
</div>
</div>
}
@* Report attached to existing stack – success *@
@if (reportAttachedTo != null)
{
<div class="report-overlay" @onclick="() => reportAttachedTo = null">
<div class="report-dialog report-success" role="dialog" aria-modal="true" @onclick:stopPropagation="true">
<div class="success-icon">✓</div>
<h3>Report gespeichert</h3>
<p>Die Messwerte wurden dem Satz <span class="mono">@reportAttachedTo.TicketNumber</span> hinzugefügt.</p>
<div class="report-footer center">
<button type="button" class="btn btn-outline" @onclick="() => reportAttachedTo = null">Weiter erfassen</button>
<button type="button" class="btn btn-primary" @onclick="ResetForm">Fertig</button>
</div>
</div>
</div>
}
}
@code {
private int currentStep = 1;
private bool isInventoryMode = false;
// Step 1: Customer
private string customerSearch = "";
private List<Customer> searchResults = new();
private Customer? selectedCustomer;
private Customer newCustomer = new();
private bool showNewCustomerForm;
private bool isSearching;
// Step 1: Vehicle
private List<Vehicle> customerVehicles = new();
private Vehicle? selectedVehicle;
private Vehicle newVehicle = new();
private bool showAddVehicleForm;
// Step 1: Category (Inventory Mode)
private List<CatalogItem> categories = new();
private CatalogItem? selectedCategory;
private CatalogItem newCategory = new();
private bool showNewCategoryForm;
private IBrowserFile? categoryImageFile;
private string? categoryImagePreview;
// Step 2: Tire Stack Info
private TireStack tireStack = new();
private bool showDetailsExpanded;
private string? errorMessage;
private bool useCustomItemCount;
// Step 3: Location
private List<StorageLocation> allLocations = new();
private List<StorageLocation> filteredLocations = new();
private AreaType? selectedAreaFilter;
private string? selectedRowFilter;
private StorageLocation? selectedLocation;
// Step 4: Result
private TireStack? createdTireStack;
private bool isProcessing;
// Image Zoom
private bool showImageZoom;
private string? zoomImageUrl;
private string? zoomImageTitle;
// QR report
private enum ReportAssignMode { Existing, New }
private bool showQrScanner;
private bool isLoadingReport;
private TireReportData? scannedReport;
private TireReportData? pendingReport;
private bool pendingReportSaved;
private ReportAssignMode reportMode = ReportAssignMode.New;
private List<TireStack> customerStacksForReport = new();
private Guid? reportTargetStackId;
private string? reportError;
private TireStack? reportAttachedTo;
private const int NotesMaxLength = 1000;
private static string RatingClass(TireMeasureRating r) => r switch
{
TireMeasureRating.Ok => "ok",
TireMeasureRating.Warning => "warn",
TireMeasureRating.Critical => "crit",
_ => "none"
};
private static string RatingClass(TireReportData r, decimal? mm) => RatingClass(TireInspection.RateDepth(mm, r.CriticalThreshold, r.WarningThreshold));
private RenderFragment<TireReportData> ReportSummaryFragment => report => __builder =>
{
<div class="report-summary">
@if (!string.IsNullOrEmpty(report.LicensePlate)) { <span class="report-chip">🚗 @report.LicensePlate</span> }
@if (report.Mileage.HasValue) { <span class="report-chip">@report.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km</span> }
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFL)">VL @Fmt(report.TreadDepthFL)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFR)">VR @Fmt(report.TreadDepthFR)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRL)">HL @Fmt(report.TreadDepthRL)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRR)">HR @Fmt(report.TreadDepthRR)</span>
</div>
};
private static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
private void OpenQrScanner()
{
reportError = null;
showQrScanner = true;
}
private async Task OnQrScanned(string text)
{
showQrScanner = false;
if (selectedCustomer == null)
{
errorMessage = "Bitte zuerst einen Kunden auswählen.";
return;
}
if (!TireReportService.TryParseReportUrl(text, out var recordSn, out _))
{
errorMessage = "Der QR-Code enthält keinen gültigen Reifen-Report-Link.";
return;
}
isLoadingReport = true;
errorMessage = null;
StateHasChanged();
try
{
var report = await TireReportService.FetchAsync(recordSn);
var stacks = await TireStackService.GetByCustomerAsync(selectedCustomer.Id);
customerStacksForReport = stacks
.Where(s => s.IsActive && s.Status != TireStackStatus.PickedUp)
.OrderByDescending(s => selectedVehicle != null && s.VehicleId == selectedVehicle.Id)
.ThenByDescending(s => s.Status == TireStackStatus.Stored)
.ThenByDescending(s => s.StoredAt)
.ToList();
reportMode = customerStacksForReport.Count > 0 ? ReportAssignMode.Existing : ReportAssignMode.New;
reportTargetStackId = customerStacksForReport.FirstOrDefault()?.Id;
reportError = null;
scannedReport = report;
}
catch (TireReportException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = $"Report konnte nicht verarbeitet werden: {ex.Message}";
}
finally
{
isLoadingReport = false;
}
}
private void SetReportMode(ReportAssignMode mode)
{
reportMode = mode;
reportError = null;
}
private void CloseReportModal()
{
scannedReport = null;
reportError = null;
}
private async Task AttachReportToExisting()
{
if (scannedReport == null || reportTargetStackId == null) return;
isProcessing = true;
reportError = null;
StateHasChanged();
try
{
scannedReport.RecomputeRatings();
await TireStackService.AddInspectionAsync(scannedReport.ToInspection(reportTargetStackId.Value, UserSession.CurrentUser?.Id));
reportAttachedTo = customerStacksForReport.FirstOrDefault(s => s.Id == reportTargetStackId.Value);
scannedReport = null;
}
catch (Exception ex)
{
reportError = $"Speichern fehlgeschlagen: {ex.Message}";
}
finally
{
isProcessing = false;
}
}
private void ApplyReportToNew()
{
if (scannedReport == null) return;
scannedReport.RecomputeRatings();
pendingReport = scannedReport;
scannedReport = null;
var de = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
var lines = new List<string>();
if (!string.IsNullOrEmpty(pendingReport.LicensePlate)) lines.Add($"Kennzeichen: {pendingReport.LicensePlate}");
if (pendingReport.Mileage.HasValue) lines.Add($"km-Stand: {pendingReport.Mileage.Value.ToString("N0", de)}");
if (pendingReport.HasTreadData)
lines.Add($"Profil VL {Fmt(pendingReport.TreadDepthFL)} / VR {Fmt(pendingReport.TreadDepthFR)} / HL {Fmt(pendingReport.TreadDepthRL)} / HR {Fmt(pendingReport.TreadDepthRR)}");
if (!string.IsNullOrEmpty(pendingReport.Recommendation)) lines.Add(pendingReport.Recommendation);
var block = string.Join("\n", lines);
if (!string.IsNullOrWhiteSpace(block))
{
var merged = string.IsNullOrWhiteSpace(tireStack.Notes) ? block : tireStack.Notes.TrimEnd() + "\n" + block;
tireStack.Notes = merged.Length <= NotesMaxLength ? merged : merged[..NotesMaxLength];
showDetailsExpanded = true;
}
if (pendingReport.HasTreadData)
{
var minDepth = new[] { pendingReport.TreadDepthFL, pendingReport.TreadDepthFR, pendingReport.TreadDepthRL, pendingReport.TreadDepthRR }
.Where(v => v.HasValue).Select(v => v!.Value).Min();
tireStack.Condition = minDepth < TireInspection.DefaultWarningThreshold ? TireCondition.Poor
: minDepth < 5m ? TireCondition.Fair
: TireCondition.Good;
}
_ = SaveDraftAsync();
}
private void RemovePendingReport()
{
pendingReport = null;
_ = SaveDraftAsync();
}
// Computed helpers
private bool isFelgenCategory => isInventoryMode && selectedCategory?.Type == CatalogItemType.Felgen;
private bool CanCreateCustomer =>
!string.IsNullOrWhiteSpace(newCustomer.LastName) &&
!string.IsNullOrWhiteSpace(newVehicle.Make) &&
!string.IsNullOrWhiteSpace(newVehicle.TireSize);
private bool CanCreateVehicle =>
!string.IsNullOrWhiteSpace(newVehicle.Make) &&
!string.IsNullOrWhiteSpace(newVehicle.TireSize);
private bool CanCreateCategory =>
!string.IsNullOrWhiteSpace(newCategory.Name) &&
!string.IsNullOrWhiteSpace(newCategory.TireSize);
protected override async Task OnInitializedAsync()
{
if (UserSession.IsWorkerOrAdmin)
{
await LoadLocations();
await LoadCategories();
}
}
// ------------------------------------------------------------------
// Draft auto-save (sessionStorage, per-tab).
// Protects work-in-progress against accidental reload / circuit reset.
// Cleared on successful CompleteCheckin and on ResetForm.
// ------------------------------------------------------------------
private const string DraftKey = "tire-checkin";
private static readonly System.Text.Json.JsonSerializerOptions DraftJsonOptions = new()
{
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private bool _draftRestoreAttempted;
private bool _isRestoringDraft;
[SupplyParameterFromQuery(Name = "customerId")]
public Guid? PreselectedCustomerId { get; set; }
private CancellationTokenSource? _draftDebounceCts;
private CancellationTokenSource? _searchDebounceCts;
private readonly SemaphoreSlim _searchLock = new(1, 1);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsWorkerOrAdmin && !_draftRestoreAttempted)
{
_draftRestoreAttempted = true;
if (PreselectedCustomerId.HasValue)
{
await PreselectCustomerAsync(PreselectedCustomerId.Value);
return;
}
await TryRestoreDraftAsync();
}
}
private async Task PreselectCustomerAsync(Guid customerId)
{
try
{
var customer = await CustomerService.GetByIdAsync(customerId);
if (customer == null) return;
try { await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey); } catch { }
isInventoryMode = false;
await SelectCustomer(customer);
GoToStep2();
StateHasChanged();
}
catch (Exception ex)
{
errorMessage = $"Kunde konnte nicht geladen werden: {ex.Message}";
}
}
private async Task TryRestoreDraftAsync()
{
try
{
var json = await JS.InvokeAsync<string?>("appInterop.loadDraft", DraftKey);
if (string.IsNullOrWhiteSpace(json)) return;
var draft = System.Text.Json.JsonSerializer.Deserialize<CheckinDraft>(json, DraftJsonOptions);
if (draft == null) return;
_isRestoringDraft = true;
try
{
currentStep = draft.CurrentStep < 1 ? 1 : draft.CurrentStep > 3 ? 3 : draft.CurrentStep;
isInventoryMode = draft.IsInventoryMode;
customerSearch = draft.CustomerSearch ?? "";
useCustomItemCount = draft.UseCustomItemCount;
showDetailsExpanded = draft.ShowDetailsExpanded;
if (draft.SelectedCustomerId.HasValue)
{
var allCustomers = await CustomerService.GetAllAsync();
selectedCustomer = allCustomers.FirstOrDefault(c => c.Id == draft.SelectedCustomerId.Value);
if (selectedCustomer != null)
{
customerVehicles = await CustomerService.GetVehiclesAsync(selectedCustomer.Id);
if (draft.SelectedVehicleId.HasValue)
{
selectedVehicle = customerVehicles.FirstOrDefault(v => v.Id == draft.SelectedVehicleId.Value);
}
}
}
if (!string.IsNullOrEmpty(draft.SelectedCategoryId) && Guid.TryParse(draft.SelectedCategoryId, out var catId))
{
selectedCategory = categories.FirstOrDefault(c => c.Id == catId);
}
if (draft.TireStack != null)
{
tireStack = draft.TireStack;
}
pendingReport = draft.PendingReport;
if (draft.SelectedLocationId.HasValue)
{
selectedLocation = allLocations.FirstOrDefault(l => l.Id == draft.SelectedLocationId.Value);
}
// Mark unsaved so beforeunload warns; the user has active draft state.
await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft());
}
finally
{
_isRestoringDraft = false;
}
StateHasChanged();
}
catch
{
// Corrupt draft — wipe and continue with a fresh form.
try { await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey); } catch { }
}
}
private bool HasMeaningfulDraft()
{
if (currentStep > 1) return true;
if (selectedCustomer != null) return true;
if (selectedCategory != null) return true;
if (!string.IsNullOrWhiteSpace(customerSearch)) return true;
if (!string.IsNullOrWhiteSpace(tireStack.TireBrand)) return true;
if (!string.IsNullOrWhiteSpace(tireStack.TireModel)) return true;
if (!string.IsNullOrWhiteSpace(tireStack.Notes)) return true;
return false;
}
/// <summary>
/// Queues a debounced draft save (~400 ms). Called by the UI and
/// state-changing methods. Safe to call many times per second.
/// </summary>
private async Task SaveDraftAsync()
{
if (_isRestoringDraft) return;
_draftDebounceCts?.Cancel();
_draftDebounceCts?.Dispose();
var cts = new CancellationTokenSource();
_draftDebounceCts = cts;
try
{
await Task.Delay(400, cts.Token);
}
catch (TaskCanceledException)
{
return;
}
if (cts.IsCancellationRequested) return;
try
{
var draft = new CheckinDraft
{
CurrentStep = currentStep,
IsInventoryMode = isInventoryMode,
CustomerSearch = customerSearch,
SelectedCustomerId = selectedCustomer?.Id,
SelectedVehicleId = selectedVehicle?.Id,
SelectedCategoryId = selectedCategory?.Id.ToString(),
SelectedLocationId = selectedLocation?.Id,
UseCustomItemCount = useCustomItemCount,
ShowDetailsExpanded = showDetailsExpanded,
TireStack = tireStack,
PendingReport = pendingReport
};
var json = System.Text.Json.JsonSerializer.Serialize(draft, DraftJsonOptions);
await JS.InvokeVoidAsync("appInterop.saveDraft", DraftKey, json);
await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft());
}
catch { /* non-fatal */ }
}
private async Task ClearDraftAsync()
{
try
{
await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey);
await JS.InvokeVoidAsync("appInterop.setUnsaved", false);
}
catch { /* non-fatal */ }
}
public async ValueTask DisposeAsync()
{
_draftDebounceCts?.Cancel();
_draftDebounceCts?.Dispose();
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
_searchLock.Dispose();
// Best-effort: clear the "unsaved" flag so beforeunload doesn't
// fire on an unrelated navigation after the component is gone.
try { await JS.InvokeVoidAsync("appInterop.setUnsaved", false); } catch { }
}
private sealed class CheckinDraft
{
public int CurrentStep { get; set; } = 1;
public bool IsInventoryMode { get; set; }
public string? CustomerSearch { get; set; }
public Guid? SelectedCustomerId { get; set; }
public Guid? SelectedVehicleId { get; set; }
public string? SelectedCategoryId { get; set; }
public Guid? SelectedLocationId { get; set; }
public bool UseCustomItemCount { get; set; }
public bool ShowDetailsExpanded { get; set; }
public TireStack? TireStack { get; set; }
public TireReportData? PendingReport { get; set; }
}
private async Task LoadLocations()
{
allLocations = await StorageLocationService.GetAvailableAsync();
ApplyLocationFilter();
}
private async Task LoadCategories()
{
categories = await CatalogService.GetAllAsync();
}
private void FilterLocationsByArea(AreaType? area)
{
selectedAreaFilter = area;
ApplyLocationFilter();
}
private void ApplyLocationFilter()
{
var query = allLocations.Where(l => !l.IsOccupied && l.IsAvailable);
if (selectedAreaFilter.HasValue)
{
query = query.Where(l => l.Area == selectedAreaFilter.Value);
}
if (!string.IsNullOrEmpty(selectedRowFilter) && int.TryParse(selectedRowFilter, out var row))
{
query = query.Where(l => l.Row == row);
}
filteredLocations = query
.OrderBy(l => l.Area)
.ThenBy(l => l.Row)
.ThenBy(l => l.SlotNumber)
.ToList();
}
private void SetMode(bool inventoryMode)
{
isInventoryMode = inventoryMode;
selectedCustomer = null;
selectedCategory = null;
selectedVehicle = null;
customerVehicles.Clear();
customerSearch = "";
searchResults.Clear();
showNewCustomerForm = false;
showNewCategoryForm = false;
showAddVehicleForm = false;
newVehicle = new Vehicle();
useCustomItemCount = false;
categoryImageFile = null;
categoryImagePreview = null;
_ = SaveDraftAsync();
}
private async Task SearchCustomers()
{
if (string.IsNullOrWhiteSpace(customerSearch) || customerSearch.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;
isSearching = true;
StateHasChanged();
searchResults = await CustomerService.SearchAsync(customerSearch);
}
finally
{
isSearching = false;
_searchLock.Release();
}
}
private async Task SelectCustomer(Customer customer)
{
selectedCustomer = customer;
showNewCustomerForm = false;
showAddVehicleForm = false;
newVehicle = new Vehicle();
// Load customer's vehicles
customerVehicles = await CustomerService.GetVehiclesAsync(customer.Id);
// Auto-select primary vehicle or single vehicle
if (customerVehicles.Count == 1)
{
selectedVehicle = customerVehicles[0];
}
else if (customerVehicles.Count > 1)
{
selectedVehicle = customerVehicles.FirstOrDefault(v => v.IsPrimary);
}
else
{
selectedVehicle = null;
}
ApplyVehicleTireSize();
_ = SaveDraftAsync();
}
private void SelectVehicle(Vehicle vehicle)
{
selectedVehicle = vehicle;
showAddVehicleForm = false;
ApplyVehicleTireSize();
_ = SaveDraftAsync();
}
/// <summary>
/// Übernimmt die Reifengröße des gewählten Fahrzeugs in den Reifenstapel,
/// sofern noch keine (abweichende) Größe manuell erfasst wurde.
/// </summary>
private void ApplyVehicleTireSize()
{
if (isInventoryMode) return;
if (string.IsNullOrWhiteSpace(selectedVehicle?.TireSize)) return;
if (string.IsNullOrWhiteSpace(tireStack.TireSize) || tireSizeFromVehicle)
{
OnTireSizeChanged(selectedVehicle.TireSize);
tireSizeFromVehicle = true;
}
}
private void ShowNewVehicleForm()
{
showAddVehicleForm = true;
newVehicle = new Vehicle();
}
private void CancelNewVehicle()
{
showAddVehicleForm = false;
newVehicle = new Vehicle();
}
private async Task AddNewVehicle()
{
if (!CanCreateVehicle || selectedCustomer == null) return;
try
{
var created = await CustomerService.AddVehicleAsync(selectedCustomer.Id, newVehicle);
customerVehicles.Add(created);
selectedVehicle = created;
showAddVehicleForm = false;
newVehicle = new Vehicle();
errorMessage = null;
ApplyVehicleTireSize();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Hinzufügen: {ex.Message}";
}
}
private void SelectCategory(CatalogItem category)
{
selectedCategory = category;
showNewCategoryForm = false;
tireStack.TireSize = category.TireSize;
if (category.Type == CatalogItemType.Reifen)
{
tireStack.Season = category.Season;
}
else
{
// Felgen: no season, no rims toggle (they ARE rims)
tireStack.WithRims = false;
}
_ = SaveDraftAsync();
}
private void ShowNewCustomerForm()
{
showNewCustomerForm = true;
newCustomer = new Customer();
newVehicle = new Vehicle();
}
private void CancelNewCustomer()
{
showNewCustomerForm = false;
newCustomer = new Customer();
newVehicle = new Vehicle();
}
private async Task CreateNewCustomer()
{
if (!CanCreateCustomer) return;
try
{
// Create customer with vehicle
selectedCustomer = await CustomerService.CreateWithVehicleAsync(newCustomer, newVehicle);
// Reload vehicles and select the new one
customerVehicles = await CustomerService.GetVehiclesAsync(selectedCustomer.Id);
selectedVehicle = customerVehicles.FirstOrDefault();
showNewCustomerForm = false;
searchResults.Clear();
customerSearch = "";
newCustomer = new Customer();
newVehicle = new Vehicle();
errorMessage = null;
ApplyVehicleTireSize();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Anlegen: {ex.Message}";
}
}
private void ShowNewCategoryForm()
{
showNewCategoryForm = true;
newCategory = new CatalogItem();
categoryImageFile = null;
categoryImagePreview = null;
}
private void CancelNewCategory()
{
showNewCategoryForm = false;
newCategory = new CatalogItem();
categoryImageFile = null;
categoryImagePreview = null;
}
private async Task CreateNewCategory()
{
if (!CanCreateCategory) return;
try
{
// Upload image if provided (Felgen category)
if (categoryImageFile != null && newCategory.Type == CatalogItemType.Felgen)
{
using var stream = categoryImageFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var relativePath = await FileStorageService.SaveFileAsync(ms.ToArray(), categoryImageFile.Name, "catalog-images");
newCategory.PictureFileName = relativePath;
}
var created = await CatalogService.CreateAsync(newCategory);
categories.Add(created);
selectedCategory = created;
showNewCategoryForm = false;
categoryImageFile = null;
categoryImagePreview = null;
if (created.Type == CatalogItemType.Reifen)
{
tireStack.Season = created.Season;
}
else
{
tireStack.WithRims = false;
}
tireStack.TireSize = created.TireSize;
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Anlegen: {ex.Message}";
}
}
private void GoToStep(int step)
{
currentStep = step;
_ = SaveDraftAsync();
}
private void GoToStep2()
{
if (isInventoryMode && selectedCategory != null)
{
currentStep = 2;
}
else if (!isInventoryMode && selectedCustomer != null)
{
currentStep = 2;
}
_ = SaveDraftAsync();
}
private void GoBackToStep1()
{
currentStep = 1;
_ = SaveDraftAsync();
}
private void GoBackToStep2()
{
currentStep = 2;
_ = SaveDraftAsync();
}
private async Task GoToStep3()
{
await LoadLocations();
locationSelectorCollapsed = selectedLocation != null;
currentStep = 3;
_ = SaveDraftAsync();
}
private void ToggleDetails()
{
showDetailsExpanded = !showDetailsExpanded;
_ = SaveDraftAsync();
}
private bool locationSelectorCollapsed;
private void SelectLocation(StorageLocation location)
{
if (!location.IsOccupied && location.IsAvailable)
{
selectedLocation = location;
locationSelectorCollapsed = true;
_ = SaveDraftAsync();
}
}
private void ExpandLocationSelector()
{
locationSelectorCollapsed = false;
}
private void ClearSelectedLocation()
{
selectedLocation = null;
locationSelectorCollapsed = false;
}
private async Task CompleteCheckin()
{
if (selectedLocation == null) return;
if (!isInventoryMode && selectedCustomer == null) return;
if (isInventoryMode && selectedCategory == null) return;
isProcessing = true;
errorMessage = null;
StateHasChanged();
try
{
if (isInventoryMode)
{
tireStack.CustomerId = null;
tireStack.VehicleId = null;
tireStack.CatalogItemId = selectedCategory!.Id;
if (!useCustomItemCount)
{
tireStack.ItemCount = TireStack.TiresPerStack;
}
}
else
{
tireStack.CustomerId = selectedCustomer!.Id;
tireStack.VehicleId = selectedVehicle?.Id;
tireStack.ItemCount = TireStack.TiresPerStack;
}
var created = await TireStackService.CreateAndStoreAsync(
tireStack,
selectedLocation.Id,
UserSession.CurrentUser?.Id);
createdTireStack = created;
pendingReportSaved = false;
if (pendingReport != null)
{
try
{
await TireStackService.AddInspectionAsync(pendingReport.ToInspection(created.Id, UserSession.CurrentUser?.Id));
pendingReportSaved = true;
}
catch (Exception ex)
{
errorMessage = $"Stapel eingelagert, Report konnte aber nicht gespeichert werden: {ex.Message}";
}
pendingReport = null;
}
currentStep = 4;
await ClearDraftAsync();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Einlagern: {ex.Message}";
selectedLocation = null;
await LoadLocations();
}
finally
{
isProcessing = false;
}
}
private void ResetForm()
{
currentStep = 1;
customerSearch = "";
searchResults.Clear();
selectedCustomer = null;
selectedCategory = null;
selectedVehicle = null;
customerVehicles.Clear();
newCustomer = new Customer();
newVehicle = new Vehicle();
newCategory = new CatalogItem();
showNewCustomerForm = false;
showNewCategoryForm = false;
showAddVehicleForm = false;
tireStack = new TireStack();
showDetailsExpanded = false;
tireSizeFromVehicle = false;
selectedLocation = null;
selectedAreaFilter = null;
selectedRowFilter = null;
locationSelectorCollapsed = false;
createdTireStack = null;
errorMessage = null;
useCustomItemCount = false;
categoryImageFile = null;
categoryImagePreview = null;
showImageZoom = false;
zoomImageUrl = null;
zoomImageTitle = null;
showQrScanner = false;
scannedReport = null;
pendingReport = null;
reportAttachedTo = null;
reportError = null;
customerStacksForReport.Clear();
reportTargetStackId = null;
_ = ClearDraftAsync();
}
// Category type toggle
private void SetNewCategoryType(CatalogItemType type)
{
newCategory.Type = type;
if (type == CatalogItemType.Felgen)
{
categoryImageFile = null;
categoryImagePreview = null;
}
}
private void SetRimType(bool isAlu)
{
tireStack.RimSize = isAlu ? "Alu" : "Stahl";
}
private bool tireSizeFromVehicle;
private void OnTireSizeChanged(string? value)
{
if (tireStack.TireSize != value && value != selectedVehicle?.TireSize)
{
tireSizeFromVehicle = false;
}
tireStack.TireSize = value;
// Auto-derive rim size from the R value of the tire size (e.g. "225/45 R17" -> "17 Zoll")
var match = System.Text.RegularExpressions.Regex.Match(value ?? string.Empty, @"R(\d{2})$");
if (match.Success)
{
tireStack.RimSize = $"{match.Groups[1].Value} Zoll";
}
else if (!string.IsNullOrEmpty(tireStack.RimSize) && tireStack.RimSize.EndsWith(" Zoll"))
{
// Clear only auto-derived values when the R part is removed again
tireStack.RimSize = null;
}
}
// File upload for Felgen category image
private async Task OnCategoryImageSelected(InputFileChangeEventArgs e)
{
var file = e.File;
if (file == null) return;
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (!allowedTypes.Contains(file.ContentType.ToLowerInvariant()))
{
errorMessage = "Ungültiger Dateityp. Erlaubt: JPG, PNG, GIF, WEBP";
return;
}
if (file.Size > 10 * 1024 * 1024)
{
errorMessage = "Datei ist zu groß (max. 10 MB)";
return;
}
categoryImageFile = file;
errorMessage = null;
// Generate preview
try
{
var resized = await file.RequestImageFileAsync(file.ContentType, 300, 300);
using var stream = resized.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
categoryImagePreview = $"data:{file.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}";
}
catch
{
categoryImagePreview = null;
}
}
private void RemoveCategoryImage()
{
categoryImageFile = null;
categoryImagePreview = null;
}
// Item count
private void ToggleCustomItemCount()
{
useCustomItemCount = !useCustomItemCount;
if (!useCustomItemCount)
{
tireStack.ItemCount = TireStack.TiresPerStack;
}
}
private void AdjustItemCount(int delta)
{
var newCount = tireStack.ItemCount + delta;
if (newCount >= 1 && newCount <= 99)
{
tireStack.ItemCount = newCount;
}
}
// Image zoom
private string GetCategoryImageUrl(CatalogItem category)
{
if (string.IsNullOrEmpty(category.PictureFileName)) return "";
return $"/api/catalog-image/{Uri.EscapeDataString(category.PictureFileName)}";
}
private void ShowImageZoom(CatalogItem category)
{
if (string.IsNullOrEmpty(category.PictureFileName)) return;
zoomImageUrl = GetCategoryImageUrl(category);
zoomImageTitle = category.Name;
showImageZoom = true;
}
private void CloseImageZoom()
{
showImageZoom = false;
zoomImageUrl = null;
zoomImageTitle = null;
}
}