2203 lines
92 KiB
Plaintext
2203 lines
92 KiB
Plaintext
@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;
|
||
}
|
||
}
|