Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes
This commit is contained in:
1 parent
cba9414e92
commit
5d87a73b77
25 files changed
+5104
-742
No files matched your search
@@ -236,6 +236,7 @@
|
|||||||
@* Blazor Framework - defer loading *@
|
@* Blazor Framework - defer loading *@
|
||||||
<script src="_framework/blazor.web.js" autostart="true"></script>
|
<script src="_framework/blazor.web.js" autostart="true"></script>
|
||||||
<script src="js/app-interop.js?v=@CacheVersion" defer></script>
|
<script src="js/app-interop.js?v=@CacheVersion" defer></script>
|
||||||
|
<script src="js/qr-scanner.js?v=@CacheVersion" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -44,85 +44,54 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-bar">
|
<div class="toolbar">
|
||||||
<div class="stat">
|
<div class="stat-chips">
|
||||||
<span class="stat-value">@allLocations.Count</span>
|
<button class="stat-chip @(statusFilter == null && selectedArea == null ? "active" : "")"
|
||||||
<span class="stat-label">Gesamt</span>
|
@onclick="ResetFilters" title="Alle Filter zurücksetzen">
|
||||||
</div>
|
<span class="chip-value">@allLocations.Count</span> Gesamt
|
||||||
<div class="stat available">
|
</button>
|
||||||
<span class="stat-value">@allLocations.Count(l => !l.IsOccupied)</span>
|
<button class="stat-chip available @(statusFilter == StatusAvailable ? "active" : "")"
|
||||||
<span class="stat-label">Frei</span>
|
@onclick="FilterByStatusAvailable" title="Nur freie Plätze anzeigen">
|
||||||
</div>
|
<span class="chip-value">@allLocations.Count(l => !l.IsOccupied)</span> Frei
|
||||||
<div class="stat occupied">
|
</button>
|
||||||
<span class="stat-value">@allLocations.Count(l => l.IsOccupied)</span>
|
<button class="stat-chip occupied @(statusFilter == StatusOccupied ? "active" : "")"
|
||||||
<span class="stat-label">Belegt</span>
|
@onclick="FilterByStatusOccupied" title="Nur belegte Plätze anzeigen">
|
||||||
</div>
|
<span class="chip-value">@allLocations.Count(l => l.IsOccupied)</span> Belegt
|
||||||
<div class="stat lager">
|
</button>
|
||||||
<span class="stat-value">@allLocations.Count(l => l.Area == AreaType.Lager)</span>
|
<button class="stat-chip lager @(selectedArea == AreaType.Lager ? "active" : "")"
|
||||||
<span class="stat-label">🏭 Lager</span>
|
@onclick="() => FilterByArea(selectedArea == AreaType.Lager ? null : AreaType.Lager)" title="Nur Lager anzeigen">
|
||||||
</div>
|
<span class="chip-value">@allLocations.Count(l => l.Area == AreaType.Lager)</span> 🏭 Lager
|
||||||
<div class="stat werkstatt">
|
</button>
|
||||||
<span class="stat-value">@allLocations.Count(l => l.Area == AreaType.Werkstatt)</span>
|
<button class="stat-chip werkstatt @(selectedArea == AreaType.Werkstatt ? "active" : "")"
|
||||||
<span class="stat-label">🔧 Werkstatt</span>
|
@onclick="() => FilterByArea(selectedArea == AreaType.Werkstatt ? null : AreaType.Werkstatt)" title="Nur Werkstatt anzeigen">
|
||||||
</div>
|
<span class="chip-value">@allLocations.Count(l => l.Area == AreaType.Werkstatt)</span> 🔧 Werkstatt
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">Gebäude</span>
|
|
||||||
<div class="area-filter">
|
|
||||||
<button class="filter-btn @(selectedArea == null ? "active" : "")"
|
|
||||||
@onclick="() => FilterByArea(null)">
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn lager @(selectedArea == AreaType.Lager ? "active" : "")"
|
|
||||||
@onclick="() => FilterByArea(AreaType.Lager)">
|
|
||||||
🏭 Lager
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn werkstatt @(selectedArea == AreaType.Werkstatt ? "active" : "")"
|
|
||||||
@onclick="() => FilterByArea(AreaType.Werkstatt)">
|
|
||||||
🔧 Werkstatt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="toolbar-controls">
|
||||||
<span class="filter-label">Reihe</span>
|
|
||||||
<div class="row-filter">
|
|
||||||
<select class="form-control" @bind="selectedRow" @bind:after="ApplyFilter">
|
|
||||||
<option value="">Alle Reihen</option>
|
|
||||||
@for (int r = StorageLocation.MinRow; r <= StorageLocation.MaxRowLager; r++)
|
|
||||||
{
|
|
||||||
<option value="@r">Reihe @r</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">Status</span>
|
|
||||||
<div class="status-filter">
|
|
||||||
<button class="filter-btn @(statusFilter == null ? "active" : "")"
|
|
||||||
@onclick="() => FilterByStatus(null)">
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn available @(statusFilter == StatusAvailable ? "active" : "")"
|
|
||||||
@onclick="FilterByStatusAvailable">
|
|
||||||
Frei
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn occupied @(statusFilter == StatusOccupied ? "active" : "")"
|
|
||||||
@onclick="FilterByStatusOccupied">
|
|
||||||
Belegt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-group search-group">
|
|
||||||
<span class="filter-label">Suche</span>
|
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input type="text" class="form-control" placeholder="Code suchen (z.B. 2135)..."
|
<input type="text" class="form-control" placeholder="🔍 Code suchen (z.B. 2135)..."
|
||||||
@bind="searchTerm" @bind:event="oninput" @onkeyup="ApplyFilter" />
|
@bind="searchTerm" @bind:event="oninput" @onkeyup="ApplyFilter" />
|
||||||
</div>
|
</div>
|
||||||
|
<select class="form-control row-select" @bind="selectedRow" @bind:after="ApplyFilter" title="Nach Reihe filtern">
|
||||||
|
<option value="">Alle Reihen</option>
|
||||||
|
@for (int r = StorageLocation.MinRow; r <= StorageLocation.MaxRowLager; r++)
|
||||||
|
{
|
||||||
|
<option value="@r">Reihe @r</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (viewMode == "grid")
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline btn-sm" @onclick="ToggleAllRows" title="Alle Reihen ein-/ausklappen">
|
||||||
|
@(allRowsCollapsed ? "▶ Alle aufklappen" : "▼ Alle zuklappen")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (HasActiveFilters)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline btn-sm clear-filters" @onclick="ResetFilters" title="Filter zurücksetzen">
|
||||||
|
✕ Filter löschen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -456,7 +425,85 @@ else
|
|||||||
{
|
{
|
||||||
var ts = selectedLocation.OccupiedTireStack;
|
var ts = selectedLocation.OccupiedTireStack;
|
||||||
<div class="stored-tires">
|
<div class="stored-tires">
|
||||||
<h4>📦 Eingelagerter Reifenstapel</h4>
|
<div class="stored-tires-header">
|
||||||
|
<h4>📦 Eingelagerter Reifenstapel</h4>
|
||||||
|
@if (!isEditingTireStack)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline btn-sm" @onclick="StartEditTireStack">✏️ Bearbeiten</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (isEditingTireStack && editTireStack != null)
|
||||||
|
{
|
||||||
|
<div class="tirestack-edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Saison</label>
|
||||||
|
<div class="season-selector">
|
||||||
|
<button class="season-btn @(editTireStack.Season == TireSeason.Sommer ? "active summer" : "")"
|
||||||
|
@onclick="() => editTireStack.Season = TireSeason.Sommer">
|
||||||
|
☀️ Sommer
|
||||||
|
</button>
|
||||||
|
<button class="season-btn @(editTireStack.Season == TireSeason.Winter ? "active winter" : "")"
|
||||||
|
@onclick="() => editTireStack.Season = TireSeason.Winter">
|
||||||
|
❄️ Winter
|
||||||
|
</button>
|
||||||
|
<button class="season-btn @(editTireStack.Season == TireSeason.Allrad ? "active allseason" : "")"
|
||||||
|
@onclick="() => editTireStack.Season = TireSeason.Allrad">
|
||||||
|
🔄 Ganzjahr
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Reifengröße</label>
|
||||||
|
<TireSizeInput @bind-Value="editTireStack.TireSize" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profiltiefe (mm)</label>
|
||||||
|
<input type="number" step="0.5" min="0" max="20" class="form-control" @bind="editTireStack.TreadDepth" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Marke</label>
|
||||||
|
<input type="text" class="form-control" @bind="editTireStack.TireBrand" placeholder="z.B. Michelin" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Modell</label>
|
||||||
|
<input type="text" class="form-control" @bind="editTireStack.TireModel" placeholder="z.B. Pilot Sport 4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mit Felgen?</label>
|
||||||
|
<div class="toggle-group">
|
||||||
|
<button class="toggle-btn @(editTireStack.WithRims ? "active" : "")"
|
||||||
|
@onclick="() => editTireStack.WithRims = true">Ja</button>
|
||||||
|
<button class="toggle-btn @(!editTireStack.WithRims ? "active" : "")"
|
||||||
|
@onclick="() => editTireStack.WithRims = false">Nein</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (editTireStack.WithRims)
|
||||||
|
{
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Felgengröße</label>
|
||||||
|
<input type="text" class="form-control" @bind="editTireStack.RimSize" placeholder="z.B. 17 Zoll" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notizen</label>
|
||||||
|
<textarea class="form-control" rows="2" @bind="editTireStack.Notes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-outline btn-sm" @onclick="CancelEditTireStack" disabled="@isSavingTireStack">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="SaveTireStack" disabled="@isSavingTireStack">
|
||||||
|
@(isSavingTireStack ? "Speichern..." : "💾 Speichern")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<div class="stored-item">
|
<div class="stored-item">
|
||||||
<div class="stored-header">
|
<div class="stored-header">
|
||||||
<span class="stored-customer">
|
<span class="stored-customer">
|
||||||
@@ -486,6 +533,7 @@ else
|
|||||||
Eingelagert am @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy") • @ts.DaysStored Tage
|
Eingelagert am @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy") • @ts.DaysStored Tage
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +725,11 @@ else
|
|||||||
private StorageLocation? selectedLocation;
|
private StorageLocation? selectedLocation;
|
||||||
private string? detailsError;
|
private string? detailsError;
|
||||||
|
|
||||||
|
// Tire stack inline editing
|
||||||
|
private bool isEditingTireStack;
|
||||||
|
private bool isSavingTireStack;
|
||||||
|
private TireStack? editTireStack;
|
||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
private bool showDeleteConfirm;
|
private bool showDeleteConfirm;
|
||||||
private StorageLocation? locationToDelete;
|
private StorageLocation? locationToDelete;
|
||||||
@@ -820,8 +873,44 @@ else
|
|||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FilterByStatusAvailable() => FilterByStatus(StatusAvailable);
|
private void FilterByStatusAvailable() => FilterByStatus(statusFilter == StatusAvailable ? null : StatusAvailable);
|
||||||
private void FilterByStatusOccupied() => FilterByStatus(StatusOccupied);
|
private void FilterByStatusOccupied() => FilterByStatus(statusFilter == StatusOccupied ? null : StatusOccupied);
|
||||||
|
|
||||||
|
private bool HasActiveFilters =>
|
||||||
|
selectedArea != null || statusFilter != null ||
|
||||||
|
!string.IsNullOrEmpty(selectedRow) || !string.IsNullOrEmpty(searchTerm);
|
||||||
|
|
||||||
|
private void ResetFilters()
|
||||||
|
{
|
||||||
|
selectedArea = null;
|
||||||
|
statusFilter = null;
|
||||||
|
selectedRow = null;
|
||||||
|
searchTerm = "";
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool allRowsCollapsed =>
|
||||||
|
filteredLocations.Count > 0 &&
|
||||||
|
filteredLocations.GroupBy(l => $"{l.Area}-{l.Row}").All(g => collapsedRows.Contains(g.Key));
|
||||||
|
|
||||||
|
private void ToggleAllRows()
|
||||||
|
{
|
||||||
|
var rowKeys = filteredLocations
|
||||||
|
.GroupBy(l => $"{l.Area}-{l.Row}")
|
||||||
|
.Select(g => g.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (allRowsCollapsed)
|
||||||
|
{
|
||||||
|
foreach (var key in rowKeys)
|
||||||
|
collapsedRows.Remove(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var key in rowKeys)
|
||||||
|
collapsedRows.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyFilter()
|
private void ApplyFilter()
|
||||||
{
|
{
|
||||||
@@ -869,6 +958,8 @@ else
|
|||||||
private async Task ShowLocationDetails(StorageLocation location)
|
private async Task ShowLocationDetails(StorageLocation location)
|
||||||
{
|
{
|
||||||
detailsError = null;
|
detailsError = null;
|
||||||
|
isEditingTireStack = false;
|
||||||
|
editTireStack = null;
|
||||||
selectedLocation = await StorageLocationService.GetByIdAsync(location.Id);
|
selectedLocation = await StorageLocationService.GetByIdAsync(location.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,6 +967,78 @@ else
|
|||||||
{
|
{
|
||||||
selectedLocation = null;
|
selectedLocation = null;
|
||||||
detailsError = null;
|
detailsError = null;
|
||||||
|
isEditingTireStack = false;
|
||||||
|
editTireStack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartEditTireStack()
|
||||||
|
{
|
||||||
|
var ts = selectedLocation?.OccupiedTireStack;
|
||||||
|
if (ts == null) return;
|
||||||
|
|
||||||
|
// Edit a copy so cancel does not mutate the displayed entity
|
||||||
|
editTireStack = new TireStack
|
||||||
|
{
|
||||||
|
Id = ts.Id,
|
||||||
|
Season = ts.Season,
|
||||||
|
TireSize = ts.TireSize,
|
||||||
|
TireBrand = ts.TireBrand,
|
||||||
|
TireModel = ts.TireModel,
|
||||||
|
TreadDepth = ts.TreadDepth,
|
||||||
|
WithRims = ts.WithRims,
|
||||||
|
RimSize = ts.RimSize,
|
||||||
|
Notes = ts.Notes
|
||||||
|
};
|
||||||
|
isEditingTireStack = true;
|
||||||
|
detailsError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEditTireStack()
|
||||||
|
{
|
||||||
|
isEditingTireStack = false;
|
||||||
|
editTireStack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveTireStack()
|
||||||
|
{
|
||||||
|
if (editTireStack == null || selectedLocation == null) return;
|
||||||
|
|
||||||
|
isSavingTireStack = true;
|
||||||
|
detailsError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stack = await TireStackService.GetByIdAsync(editTireStack.Id);
|
||||||
|
if (stack == null)
|
||||||
|
{
|
||||||
|
detailsError = "Reifenstapel nicht gefunden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Season = editTireStack.Season;
|
||||||
|
stack.TireSize = editTireStack.TireSize;
|
||||||
|
stack.TireBrand = editTireStack.TireBrand;
|
||||||
|
stack.TireModel = editTireStack.TireModel;
|
||||||
|
stack.TreadDepth = editTireStack.TreadDepth;
|
||||||
|
stack.WithRims = editTireStack.WithRims;
|
||||||
|
stack.RimSize = editTireStack.WithRims ? editTireStack.RimSize : null;
|
||||||
|
stack.Notes = editTireStack.Notes;
|
||||||
|
|
||||||
|
await TireStackService.UpdateAsync(stack);
|
||||||
|
|
||||||
|
// Refresh modal + list so chips/season colors update everywhere
|
||||||
|
selectedLocation = await StorageLocationService.GetByIdAsync(selectedLocation.Id);
|
||||||
|
await LoadLocations();
|
||||||
|
isEditingTireStack = false;
|
||||||
|
editTireStack = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
detailsError = $"Fehler beim Speichern: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSavingTireStack = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowAddLocation()
|
private async Task ShowAddLocation()
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,137 +134,156 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Statistics Bar - Responsive
|
Compact Toolbar (Stats + Filter combined)
|
||||||
======================================== */
|
======================================== */
|
||||||
.stats-bar {
|
.toolbar {
|
||||||
display: grid;
|
position: sticky;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
top: 0;
|
||||||
gap: 0.75rem;
|
z-index: 10;
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 0.5px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.stats-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.stats-bar {
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
text-align: center;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.stat {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 80px;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-right: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
display: block;
|
|
||||||
font-family: var(--font-family-heading);
|
|
||||||
font-size: clamp(1.25rem, 4vw, 1.75rem);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat.available .stat-value {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat.occupied .stat-value {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-family: var(--font-family-base);
|
|
||||||
font-size: clamp(0.65rem, 2vw, 0.75rem);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Filter Bar - Responsive
|
|
||||||
======================================== */
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 1rem;
|
padding: 0.6rem 0.75rem;
|
||||||
background: rgba(28, 28, 30, 0.4);
|
background: rgba(28, 28, 30, 0.92);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 0.5px solid var(--border-light);
|
border: 0.5px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 1024px) {
|
||||||
.filter-bar {
|
.toolbar {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
gap: 0.75rem;
|
||||||
gap: 1.25rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group {
|
/* Stat chips - clickable, act as filters */
|
||||||
display: flex;
|
.stat-chips {
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-family: var(--font-family-base);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group.search-group {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
|
||||||
.filter-group.search-group {
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Area & Status Filter */
|
|
||||||
.area-filter,
|
|
||||||
.status-filter {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Row Filter */
|
.stat-chip {
|
||||||
.row-filter {
|
display: inline-flex;
|
||||||
min-width: 120px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 0.5px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) var(--ease-default);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip .chip-value {
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.active {
|
||||||
|
background: rgba(220, 38, 38, 0.15);
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.available .chip-value {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.available.active {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border-color: rgba(34, 197, 94, 0.4);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.occupied .chip-value {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.occupied.active {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
border-color: rgba(251, 191, 36, 0.4);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.lager.active {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
border-color: rgba(251, 191, 36, 0.4);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.werkstatt.active {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
color: var(--color-info-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar controls: search + row select + collapse toggle */
|
||||||
|
.toolbar-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .row-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 110px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 0.5px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .btn-sm {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
min-height: 34px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 0.5px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all var(--duration-fast) var(--ease-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .btn-sm:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .clear-filters {
|
||||||
|
color: var(--color-error-light);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter Buttons */
|
/* Filter Buttons */
|
||||||
@@ -323,14 +342,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-box .form-control {
|
.search-box .form-control {
|
||||||
padding: 10px 14px;
|
padding: 7px 12px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 0.5px solid var(--border-default);
|
border: 0.5px solid var(--border-default);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box .form-control:focus {
|
.search-box .form-control:focus {
|
||||||
@@ -400,8 +420,8 @@
|
|||||||
Area Section (Grid View)
|
Area Section (Grid View)
|
||||||
======================================== */
|
======================================== */
|
||||||
.area-section {
|
.area-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
background: rgba(28, 28, 30, 0.4);
|
background: rgba(28, 28, 30, 0.4);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
border: 0.5px solid var(--border-light);
|
border: 0.5px solid var(--border-light);
|
||||||
@@ -409,8 +429,8 @@
|
|||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.area-section {
|
.area-section {
|
||||||
padding: 1.25rem;
|
padding: 1rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,22 +444,32 @@
|
|||||||
|
|
||||||
.area-title {
|
.area-title {
|
||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-base);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 0.6rem 0;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.4rem;
|
||||||
border-bottom: 0.5px solid var(--border-default);
|
border-bottom: 0.5px solid var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-section {
|
.row-section {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-section:last-child {
|
.row-section:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.position-title {
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0.5rem 0 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.row-header {
|
.row-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -447,8 +477,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.35rem 0;
|
padding: 0.25rem 0.25rem;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.4rem 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -500,7 +530,7 @@
|
|||||||
.location-grid {
|
.location-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 400px) {
|
@media (min-width: 400px) {
|
||||||
@@ -511,14 +541,14 @@
|
|||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.location-grid {
|
.location-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.location-grid {
|
.location-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(115px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,11 +557,11 @@
|
|||||||
background: var(--surface-elevated);
|
background: var(--surface-elevated);
|
||||||
border: 0.5px solid var(--border-default);
|
border: 0.5px solid var(--border-default);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--duration-fast) var(--ease-default);
|
transition: all var(--duration-fast) var(--ease-default);
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 70px;
|
min-height: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-card:hover {
|
.location-card:hover {
|
||||||
@@ -1144,6 +1174,83 @@
|
|||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stored-tires-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stored-tires-header h4 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-selector,
|
||||||
|
.tirestack-edit-form .toggle-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-btn,
|
||||||
|
.tirestack-edit-form .toggle-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0.5px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-btn:hover,
|
||||||
|
.tirestack-edit-form .toggle-btn:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-btn.active,
|
||||||
|
.tirestack-edit-form .toggle-btn.active {
|
||||||
|
border-color: var(--accent, #4a9eff);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-btn.active.summer {
|
||||||
|
border-color: #f5a623;
|
||||||
|
background: rgba(245, 166, 35, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-btn.active.winter {
|
||||||
|
border-color: #4a9eff;
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .season-btn.active.allseason {
|
||||||
|
border-color: #7ed321;
|
||||||
|
background: rgba(126, 211, 33, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tirestack-edit-form .form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.stored-item {
|
.stored-item {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@inject IStorageLocationService StorageLocationService
|
@inject IStorageLocationService StorageLocationService
|
||||||
@inject ILocationCodeService LocationCodeService
|
@inject ILocationCodeService LocationCodeService
|
||||||
@inject ITireStackService TireStackService
|
@inject ITireStackService TireStackService
|
||||||
|
@inject ITireReportService TireReportService
|
||||||
@inject ICatalogService CatalogService
|
@inject ICatalogService CatalogService
|
||||||
@inject IFileStorageService FileStorageService
|
@inject IFileStorageService FileStorageService
|
||||||
@inject UserSessionService UserSession
|
@inject UserSessionService UserSession
|
||||||
@@ -474,6 +475,42 @@ else
|
|||||||
</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 *@
|
@* Tire/Felgen Form *@
|
||||||
<div class="tire-form">
|
<div class="tire-form">
|
||||||
@if (!isFelgenCategory)
|
@if (!isFelgenCategory)
|
||||||
@@ -506,6 +543,10 @@ else
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<TireSizeInput Value="@tireStack.TireSize" ValueChanged="OnTireSizeChanged" />
|
<TireSizeInput Value="@tireStack.TireSize" ValueChanged="OnTireSizeChanged" />
|
||||||
|
@if (tireSizeFromVehicle && !string.IsNullOrEmpty(tireStack.TireSize))
|
||||||
|
{
|
||||||
|
<span class="field-hint">🚗 Vom Fahrzeug übernommen</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -657,7 +698,7 @@ else
|
|||||||
@* Step 3: Storage Location *@
|
@* Step 3: Storage Location *@
|
||||||
@if (currentStep == 3)
|
@if (currentStep == 3)
|
||||||
{
|
{
|
||||||
<div class="checkin-card">
|
<div class="checkin-card wide">
|
||||||
<div class="selected-info-bar">
|
<div class="selected-info-bar">
|
||||||
@if (isInventoryMode)
|
@if (isInventoryMode)
|
||||||
{
|
{
|
||||||
@@ -676,32 +717,104 @@ else
|
|||||||
|
|
||||||
<h2>Lagerplatz auswählen</h2>
|
<h2>Lagerplatz auswählen</h2>
|
||||||
|
|
||||||
<div class="location-selector">
|
@if (selectedLocation != null)
|
||||||
@* Area Filter *@
|
{
|
||||||
<div class="area-filter">
|
<div class="checkin-summary">
|
||||||
<button class="filter-btn @(selectedAreaFilter == null ? "active" : "")"
|
<h3 class="summary-title">📋 Zusammenfassung</h3>
|
||||||
@onclick="() => FilterLocationsByArea(null)">
|
<div class="summary-grid">
|
||||||
Alle
|
@if (isInventoryMode)
|
||||||
</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>
|
|
||||||
|
|
||||||
@* Row Filter *@
|
|
||||||
<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>
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Kategorie</span>
|
||||||
|
<span class="summary-value">📦 @selectedCategory?.Name</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</select>
|
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>
|
</div>
|
||||||
|
|
||||||
@* Location Grid *@
|
@* Location Grid *@
|
||||||
@@ -742,16 +855,6 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedLocation != null)
|
|
||||||
{
|
|
||||||
<div class="selected-location">
|
|
||||||
<div class="location-icon">📍</div>
|
|
||||||
<div class="location-info">
|
|
||||||
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" ShowIcon="false" />
|
|
||||||
</div>
|
|
||||||
<button class="btn-clear" @onclick="ClearSelectedLocation">✕</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
@@ -873,6 +976,159 @@ else
|
|||||||
</div>
|
</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 {
|
@code {
|
||||||
@@ -923,6 +1179,179 @@ else
|
|||||||
private string? zoomImageUrl;
|
private string? zoomImageUrl;
|
||||||
private string? zoomImageTitle;
|
private string? zoomImageTitle;
|
||||||
|
|
||||||
|
// QR report
|
||||||
|
private enum ReportAssignMode { Existing, New }
|
||||||
|
private bool showQrScanner;
|
||||||
|
private bool isLoadingReport;
|
||||||
|
private TireReportData? scannedReport;
|
||||||
|
private TireReportData? pendingReport;
|
||||||
|
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
|
// Computed helpers
|
||||||
private bool isFelgenCategory => isInventoryMode && selectedCategory?.Type == CatalogItemType.Felgen;
|
private bool isFelgenCategory => isInventoryMode && selectedCategory?.Type == CatalogItemType.Felgen;
|
||||||
|
|
||||||
@@ -954,8 +1383,16 @@ else
|
|||||||
// Cleared on successful CompleteCheckin and on ResetForm.
|
// Cleared on successful CompleteCheckin and on ResetForm.
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
private const string DraftKey = "tire-checkin";
|
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 _draftRestoreAttempted;
|
||||||
private bool _isRestoringDraft;
|
private bool _isRestoringDraft;
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "customerId")]
|
||||||
|
public Guid? PreselectedCustomerId { get; set; }
|
||||||
private CancellationTokenSource? _draftDebounceCts;
|
private CancellationTokenSource? _draftDebounceCts;
|
||||||
private CancellationTokenSource? _searchDebounceCts;
|
private CancellationTokenSource? _searchDebounceCts;
|
||||||
private readonly SemaphoreSlim _searchLock = new(1, 1);
|
private readonly SemaphoreSlim _searchLock = new(1, 1);
|
||||||
@@ -965,10 +1402,37 @@ else
|
|||||||
if (firstRender && UserSession.IsWorkerOrAdmin && !_draftRestoreAttempted)
|
if (firstRender && UserSession.IsWorkerOrAdmin && !_draftRestoreAttempted)
|
||||||
{
|
{
|
||||||
_draftRestoreAttempted = true;
|
_draftRestoreAttempted = true;
|
||||||
|
|
||||||
|
if (PreselectedCustomerId.HasValue)
|
||||||
|
{
|
||||||
|
await PreselectCustomerAsync(PreselectedCustomerId.Value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await TryRestoreDraftAsync();
|
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()
|
private async Task TryRestoreDraftAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -976,7 +1440,7 @@ else
|
|||||||
var json = await JS.InvokeAsync<string?>("appInterop.loadDraft", DraftKey);
|
var json = await JS.InvokeAsync<string?>("appInterop.loadDraft", DraftKey);
|
||||||
if (string.IsNullOrWhiteSpace(json)) return;
|
if (string.IsNullOrWhiteSpace(json)) return;
|
||||||
|
|
||||||
var draft = System.Text.Json.JsonSerializer.Deserialize<CheckinDraft>(json);
|
var draft = System.Text.Json.JsonSerializer.Deserialize<CheckinDraft>(json, DraftJsonOptions);
|
||||||
if (draft == null) return;
|
if (draft == null) return;
|
||||||
|
|
||||||
_isRestoringDraft = true;
|
_isRestoringDraft = true;
|
||||||
@@ -1012,6 +1476,8 @@ else
|
|||||||
tireStack = draft.TireStack;
|
tireStack = draft.TireStack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingReport = draft.PendingReport;
|
||||||
|
|
||||||
if (draft.SelectedLocationId.HasValue)
|
if (draft.SelectedLocationId.HasValue)
|
||||||
{
|
{
|
||||||
selectedLocation = allLocations.FirstOrDefault(l => l.Id == draft.SelectedLocationId.Value);
|
selectedLocation = allLocations.FirstOrDefault(l => l.Id == draft.SelectedLocationId.Value);
|
||||||
@@ -1083,10 +1549,11 @@ else
|
|||||||
SelectedLocationId = selectedLocation?.Id,
|
SelectedLocationId = selectedLocation?.Id,
|
||||||
UseCustomItemCount = useCustomItemCount,
|
UseCustomItemCount = useCustomItemCount,
|
||||||
ShowDetailsExpanded = showDetailsExpanded,
|
ShowDetailsExpanded = showDetailsExpanded,
|
||||||
TireStack = tireStack
|
TireStack = tireStack,
|
||||||
|
PendingReport = pendingReport
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(draft);
|
var json = System.Text.Json.JsonSerializer.Serialize(draft, DraftJsonOptions);
|
||||||
await JS.InvokeVoidAsync("appInterop.saveDraft", DraftKey, json);
|
await JS.InvokeVoidAsync("appInterop.saveDraft", DraftKey, json);
|
||||||
await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft());
|
await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft());
|
||||||
}
|
}
|
||||||
@@ -1127,6 +1594,7 @@ else
|
|||||||
public bool UseCustomItemCount { get; set; }
|
public bool UseCustomItemCount { get; set; }
|
||||||
public bool ShowDetailsExpanded { get; set; }
|
public bool ShowDetailsExpanded { get; set; }
|
||||||
public TireStack? TireStack { get; set; }
|
public TireStack? TireStack { get; set; }
|
||||||
|
public TireReportData? PendingReport { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadLocations()
|
private async Task LoadLocations()
|
||||||
@@ -1251,6 +1719,8 @@ else
|
|||||||
selectedVehicle = null;
|
selectedVehicle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyVehicleTireSize();
|
||||||
|
|
||||||
_ = SaveDraftAsync();
|
_ = SaveDraftAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,9 +1728,26 @@ else
|
|||||||
{
|
{
|
||||||
selectedVehicle = vehicle;
|
selectedVehicle = vehicle;
|
||||||
showAddVehicleForm = false;
|
showAddVehicleForm = false;
|
||||||
|
ApplyVehicleTireSize();
|
||||||
_ = SaveDraftAsync();
|
_ = 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()
|
private void ShowNewVehicleForm()
|
||||||
{
|
{
|
||||||
showAddVehicleForm = true;
|
showAddVehicleForm = true;
|
||||||
@@ -1285,6 +1772,7 @@ else
|
|||||||
showAddVehicleForm = false;
|
showAddVehicleForm = false;
|
||||||
newVehicle = new Vehicle();
|
newVehicle = new Vehicle();
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
ApplyVehicleTireSize();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1344,6 +1832,7 @@ else
|
|||||||
newCustomer = new Customer();
|
newCustomer = new Customer();
|
||||||
newVehicle = new Vehicle();
|
newVehicle = new Vehicle();
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
ApplyVehicleTireSize();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1441,6 +1930,7 @@ else
|
|||||||
private async Task GoToStep3()
|
private async Task GoToStep3()
|
||||||
{
|
{
|
||||||
await LoadLocations();
|
await LoadLocations();
|
||||||
|
locationSelectorCollapsed = selectedLocation != null;
|
||||||
currentStep = 3;
|
currentStep = 3;
|
||||||
_ = SaveDraftAsync();
|
_ = SaveDraftAsync();
|
||||||
}
|
}
|
||||||
@@ -1451,18 +1941,27 @@ else
|
|||||||
_ = SaveDraftAsync();
|
_ = SaveDraftAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool locationSelectorCollapsed;
|
||||||
|
|
||||||
private void SelectLocation(StorageLocation location)
|
private void SelectLocation(StorageLocation location)
|
||||||
{
|
{
|
||||||
if (!location.IsOccupied && location.IsAvailable)
|
if (!location.IsOccupied && location.IsAvailable)
|
||||||
{
|
{
|
||||||
selectedLocation = location;
|
selectedLocation = location;
|
||||||
|
locationSelectorCollapsed = true;
|
||||||
_ = SaveDraftAsync();
|
_ = SaveDraftAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ExpandLocationSelector()
|
||||||
|
{
|
||||||
|
locationSelectorCollapsed = false;
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearSelectedLocation()
|
private void ClearSelectedLocation()
|
||||||
{
|
{
|
||||||
selectedLocation = null;
|
selectedLocation = null;
|
||||||
|
locationSelectorCollapsed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CompleteCheckin()
|
private async Task CompleteCheckin()
|
||||||
@@ -1501,6 +2000,19 @@ else
|
|||||||
|
|
||||||
createdTireStack = created;
|
createdTireStack = created;
|
||||||
|
|
||||||
|
if (pendingReport != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TireStackService.AddInspectionAsync(pendingReport.ToInspection(created.Id, UserSession.CurrentUser?.Id));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Stapel eingelagert, Report konnte aber nicht gespeichert werden: {ex.Message}";
|
||||||
|
}
|
||||||
|
pendingReport = null;
|
||||||
|
}
|
||||||
|
|
||||||
currentStep = 4;
|
currentStep = 4;
|
||||||
await ClearDraftAsync();
|
await ClearDraftAsync();
|
||||||
}
|
}
|
||||||
@@ -1533,9 +2045,11 @@ else
|
|||||||
showAddVehicleForm = false;
|
showAddVehicleForm = false;
|
||||||
tireStack = new TireStack();
|
tireStack = new TireStack();
|
||||||
showDetailsExpanded = false;
|
showDetailsExpanded = false;
|
||||||
|
tireSizeFromVehicle = false;
|
||||||
selectedLocation = null;
|
selectedLocation = null;
|
||||||
selectedAreaFilter = null;
|
selectedAreaFilter = null;
|
||||||
selectedRowFilter = null;
|
selectedRowFilter = null;
|
||||||
|
locationSelectorCollapsed = false;
|
||||||
createdTireStack = null;
|
createdTireStack = null;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
useCustomItemCount = false;
|
useCustomItemCount = false;
|
||||||
@@ -1544,6 +2058,13 @@ else
|
|||||||
showImageZoom = false;
|
showImageZoom = false;
|
||||||
zoomImageUrl = null;
|
zoomImageUrl = null;
|
||||||
zoomImageTitle = null;
|
zoomImageTitle = null;
|
||||||
|
showQrScanner = false;
|
||||||
|
scannedReport = null;
|
||||||
|
pendingReport = null;
|
||||||
|
reportAttachedTo = null;
|
||||||
|
reportError = null;
|
||||||
|
customerStacksForReport.Clear();
|
||||||
|
reportTargetStackId = null;
|
||||||
_ = ClearDraftAsync();
|
_ = ClearDraftAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,8 +2084,14 @@ else
|
|||||||
tireStack.RimSize = isAlu ? "Alu" : "Stahl";
|
tireStack.RimSize = isAlu ? "Alu" : "Stahl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool tireSizeFromVehicle;
|
||||||
|
|
||||||
private void OnTireSizeChanged(string? value)
|
private void OnTireSizeChanged(string? value)
|
||||||
{
|
{
|
||||||
|
if (tireStack.TireSize != value && value != selectedVehicle?.TireSize)
|
||||||
|
{
|
||||||
|
tireSizeFromVehicle = false;
|
||||||
|
}
|
||||||
tireStack.TireSize = value;
|
tireStack.TireSize = value;
|
||||||
|
|
||||||
// Auto-derive rim size from the R value of the tire size (e.g. "225/45 R17" -> "17 Zoll")
|
// Auto-derive rim size from the R value of the tire size (e.g. "225/45 R17" -> "17 Zoll")
|
||||||
|
|||||||
@@ -212,6 +212,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Step 3 (Lagerplatz/Regalansicht): volle Breite nutzen */
|
||||||
|
.checkin-card.wide {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.checkin-card.wide {
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkin-card h2 {
|
.checkin-card h2 {
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: clamp(1.25rem, 4vw, 1.75rem);
|
font-size: clamp(1.25rem, 4vw, 1.75rem);
|
||||||
@@ -1192,38 +1204,90 @@ select.form-control {
|
|||||||
/* ========================================
|
/* ========================================
|
||||||
Area Filter - Responsive
|
Area Filter - Responsive
|
||||||
======================================== */
|
======================================== */
|
||||||
|
.location-filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-filter-row .row-filter {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-filter-row .row-filter .form-control {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed bar after a location was selected */
|
||||||
|
.location-collapsed-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 0.5px solid rgba(34, 197, 94, 0.35);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-collapsed-bar:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.16);
|
||||||
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-collapsed-bar .collapsed-icon {
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-collapsed-bar .collapsed-code {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-collapsed-bar .collapsed-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.area-filter {
|
.area-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.area-filter {
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
background: rgba(28, 28, 30, 0.8);
|
background: rgba(28, 28, 30, 0.8);
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif;
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-height: 44px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 10px 14px;
|
padding: 6px 12px;
|
||||||
border-radius: 10px;
|
font-size: 0.85rem;
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1266,51 +1330,51 @@ select.form-control {
|
|||||||
Area Section - Responsive
|
Area Section - Responsive
|
||||||
======================================== */
|
======================================== */
|
||||||
.area-section {
|
.area-section {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.area-section {
|
.area-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-title {
|
.area-title {
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.4rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.area-title {
|
.area-title {
|
||||||
font-size: 1.1rem;
|
font-size: 0.95rem;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-section {
|
.row-section {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.row-section {
|
.row-section {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-label {
|
.row-label {
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.row-label {
|
.row-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,50 +1383,49 @@ select.form-control {
|
|||||||
======================================== */
|
======================================== */
|
||||||
.location-grid {
|
.location-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 400px) {
|
@media (min-width: 400px) {
|
||||||
.location-grid {
|
.location-grid {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.location-grid {
|
.location-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.location-grid {
|
.location-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(85px, 1fr));
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-tile {
|
.location-tile {
|
||||||
aspect-ratio: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-height: 60px;
|
min-height: 44px;
|
||||||
|
padding: 4px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.location-tile {
|
.location-tile {
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
border-radius: 10px;
|
min-height: 48px;
|
||||||
min-height: 70px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,6 +1492,110 @@ select.form-control {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Checkin Summary (before saving)
|
||||||
|
======================================== */
|
||||||
|
.checkin-summary {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(28, 28, 30, 0.6));
|
||||||
|
border: 0.5px solid rgba(34, 197, 94, 0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
animation: summary-fade-in 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes summary-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item.highlight {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
border: 0.5px solid rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.location {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field hint (e.g. tire size taken from vehicle) */
|
||||||
|
.field-hint {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
.location-icon {
|
.location-icon {
|
||||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@@ -2237,3 +2404,390 @@ select.form-control {
|
|||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
QR Report Scan (Step 2)
|
||||||
|
======================================== */
|
||||||
|
.report-scan-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 0.5px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-scan-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-scan-icon {
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-scan-bar .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-attached {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-attached-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-attached-head .btn-link {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chip {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chip.rating-ok { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.4); color: #86efac; }
|
||||||
|
.report-chip.rating-warn { background: rgba(250, 204, 21, 0.15); border-color: rgba(250, 204, 21, 0.4); color: #fde047; }
|
||||||
|
.report-chip.rating-crit { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
|
||||||
|
|
||||||
|
/* Report modal */
|
||||||
|
.report-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 1150;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-dialog {
|
||||||
|
background: #1c1c1e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 92vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-subtitle {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-body {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-warning {
|
||||||
|
background: rgba(250, 204, 21, 0.1);
|
||||||
|
border: 0.5px solid rgba(250, 204, 21, 0.35);
|
||||||
|
color: #fde047;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-meta .form-group {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-cell {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-cell.rating-ok { border-color: rgba(34, 197, 94, 0.5); background: rgba(34, 197, 94, 0.08); }
|
||||||
|
.tread-cell.rating-warn { border-color: rgba(250, 204, 21, 0.5); background: rgba(250, 204, 21, 0.08); }
|
||||||
|
.tread-cell.rating-crit { border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.08); }
|
||||||
|
|
||||||
|
.tread-cell label {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-input .form-control {
|
||||||
|
max-width: 110px;
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-input span {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-recommendation {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
|
border: 0.5px solid rgba(96, 165, 250, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-link {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #60a5fa;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-choice h4 {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-choice-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn.active {
|
||||||
|
border-color: #dc2626;
|
||||||
|
background: rgba(220, 38, 38, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-icon {
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-sub {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pick-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pick-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pick-item:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pick-item.selected {
|
||||||
|
border-color: #dc2626;
|
||||||
|
background: rgba(220, 38, 38, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pick-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pick-sub {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer.center {
|
||||||
|
justify-content: center;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-success {
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-success h3 {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0.5rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-success p {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.report-meta,
|
||||||
|
.tread-grid,
|
||||||
|
.report-choice-buttons {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
@page "/tire-checkout"
|
@page "/tire-checkout"
|
||||||
@using Lagerbestand.Components.Shared
|
@using Lagerbestand.Components.Shared
|
||||||
@implements IDisposable
|
@implements IAsyncDisposable
|
||||||
@inject ICustomerService CustomerService
|
@inject ICustomerService CustomerService
|
||||||
@inject ITireStackService TireStackService
|
@inject ITireStackService TireStackService
|
||||||
|
@inject IStorageLocationService StorageLocationService
|
||||||
@inject UserSessionService UserSession
|
@inject UserSessionService UserSession
|
||||||
|
|
||||||
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
|
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
|
||||||
@@ -169,6 +170,164 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Tire Swap Panel *@
|
||||||
|
@if (swapOutgoing != null)
|
||||||
|
{
|
||||||
|
<section class="swap-panel" aria-labelledby="swap-heading">
|
||||||
|
<header class="swap-header">
|
||||||
|
<h2 id="swap-heading"><span aria-hidden="true">🔄</span> Reifenwechsel</h2>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelSwap" disabled="@isProcessing">Abbrechen</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="swap-columns">
|
||||||
|
@* Outgoing *@
|
||||||
|
<div class="swap-box outgoing">
|
||||||
|
<h3 class="swap-box-title"><span aria-hidden="true">⬆️</span> Herausgabe (wird montiert)</h3>
|
||||||
|
<dl class="swap-rows">
|
||||||
|
<div class="swap-row"><dt>Ticket</dt><dd class="mono">@swapOutgoing.TicketNumber</dd></div>
|
||||||
|
<div class="swap-row"><dt>Kunde</dt><dd>@swapOutgoing.Customer?.FullName</dd></div>
|
||||||
|
@if (swapOutgoing.Vehicle != null)
|
||||||
|
{
|
||||||
|
<div class="swap-row"><dt>Fahrzeug</dt><dd>@swapOutgoing.Vehicle.Make @swapOutgoing.Vehicle.Model</dd></div>
|
||||||
|
}
|
||||||
|
<div class="swap-row"><dt>Saison</dt><dd>@SeasonLabel(swapOutgoing.Season)</dd></div>
|
||||||
|
<div class="swap-row"><dt>Größe</dt><dd class="mono">@(swapOutgoing.TireSize ?? "-")</dd></div>
|
||||||
|
<div class="swap-row"><dt>Marke / Modell</dt><dd>@(string.IsNullOrWhiteSpace(swapOutgoing.TireBrand) && string.IsNullOrWhiteSpace(swapOutgoing.TireModel) ? "-" : $"{swapOutgoing.TireBrand} {swapOutgoing.TireModel}".Trim())</dd></div>
|
||||||
|
<div class="swap-row"><dt>Lagerplatz</dt><dd>
|
||||||
|
@if (swapOutgoing.StorageLocation != null)
|
||||||
|
{
|
||||||
|
<LocationDisplay Code="@swapOutgoing.StorageLocation.Code" DisplayMode="LocationDisplayMode.Compact" Size="LocationDisplaySize.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>-</span>
|
||||||
|
}
|
||||||
|
</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Incoming *@
|
||||||
|
<div class="swap-box incoming">
|
||||||
|
<h3 class="swap-box-title"><span aria-hidden="true">⬇️</span> Einlagerung (kommt vom Fahrzeug)</h3>
|
||||||
|
|
||||||
|
<div class="swap-tabs" role="tablist">
|
||||||
|
<button type="button" class="swap-tab @(swapUseHistory ? "active" : "")" role="tab" aria-selected="@swapUseHistory"
|
||||||
|
@onclick="() => swapUseHistory = true" disabled="@(swapHistory.Count == 0)">
|
||||||
|
Aus Historie (@swapHistory.Count)
|
||||||
|
</button>
|
||||||
|
<button type="button" class="swap-tab @(!swapUseHistory ? "active" : "")" role="tab" aria-selected="@(!swapUseHistory)"
|
||||||
|
@onclick="() => swapUseHistory = false">
|
||||||
|
Neu erfassen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (swapUseHistory)
|
||||||
|
{
|
||||||
|
<div class="history-list" role="list">
|
||||||
|
@foreach (var h in swapHistory)
|
||||||
|
{
|
||||||
|
var isSameSeason = h.Season == swapOutgoing.Season;
|
||||||
|
<button type="button" class="history-item @(swapSelectedHistory?.Id == h.Id ? "selected" : "") @(isSameSeason ? "same-season" : "")"
|
||||||
|
role="listitem" @onclick="() => swapSelectedHistory = h">
|
||||||
|
<span class="history-season">@SeasonIcon(h.Season)</span>
|
||||||
|
<span class="history-main">
|
||||||
|
<span class="mono">@h.TicketNumber</span>
|
||||||
|
<span class="history-detail">@(h.TireSize ?? "-") @(string.IsNullOrWhiteSpace(h.TireBrand) ? "" : $"| {h.TireBrand} {h.TireModel}".Trim()) @(h.WithRims ? "| mit Felgen" : "")</span>
|
||||||
|
<span class="history-meta">@h.StatusText @(h.PickedUpAt.HasValue ? $"seit {h.PickedUpAt.Value.ToLocalTime():dd.MM.yyyy}" : "")</span>
|
||||||
|
</span>
|
||||||
|
@if (isSameSeason)
|
||||||
|
{
|
||||||
|
<span class="history-warn" title="Gleiche Saison wie Herausgabe">⚠️</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="swap-season">Saison</label>
|
||||||
|
<select id="swap-season" class="form-control" @bind="swapNew.Season">
|
||||||
|
<option value="@TireSeason.Sommer">☀️ Sommer</option>
|
||||||
|
<option value="@TireSeason.Winter">❄️ Winter</option>
|
||||||
|
<option value="@TireSeason.Allrad">🔄 Allrad</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="swap-size">Reifengröße</label>
|
||||||
|
<input id="swap-size" class="form-control" @bind="swapNew.TireSize" placeholder="z.B. 225/45R17" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="swap-brand">Marke</label>
|
||||||
|
<input id="swap-brand" class="form-control" @bind="swapNew.TireBrand" placeholder="z.B. Michelin" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="swap-model">Modell</label>
|
||||||
|
<input id="swap-model" class="form-control" @bind="swapNew.TireModel" placeholder="z.B. Pilot Sport 4" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="swap-rims">Felgen</label>
|
||||||
|
<select id="swap-rims" class="form-control" @bind="swapNew.WithRims">
|
||||||
|
<option value="true">Mit Felgen</option>
|
||||||
|
<option value="false">Ohne Felgen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="swap-condition">Zustand</label>
|
||||||
|
<select id="swap-condition" class="form-control" @bind="swapNew.Condition">
|
||||||
|
<option value="@TireCondition.Excellent">Sehr gut</option>
|
||||||
|
<option value="@TireCondition.Good">Gut</option>
|
||||||
|
<option value="@TireCondition.Fair">Ausreichend</option>
|
||||||
|
<option value="@TireCondition.Poor">Mangelhaft</option>
|
||||||
|
<option value="@TireCondition.Critical">Kritisch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-wide">
|
||||||
|
<label for="swap-notes">Notizen</label>
|
||||||
|
<textarea id="swap-notes" class="form-control" rows="2" @bind="swapNew.Notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Target location *@
|
||||||
|
<div class="swap-location">
|
||||||
|
<span class="swap-location-label">Lagerplatz für Einlagerung:</span>
|
||||||
|
@if (swapTargetLocation != null)
|
||||||
|
{
|
||||||
|
<LocationDisplay Code="@swapTargetLocation.Code" DisplayMode="LocationDisplayMode.Compact" Size="LocationDisplaySize.Small" />
|
||||||
|
@if (swapTargetLocation.Id == swapOutgoing.StorageLocationId)
|
||||||
|
{
|
||||||
|
<span class="swap-location-hint">(frei werdender Platz)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @onclick="ResetTargetLocation">Zurück zum alten Platz</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="swap-location-hint">Kein Lagerplatz gewählt</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @onclick="() => showLocationPicker = true">
|
||||||
|
<span aria-hidden="true">▦</span> Anderen Platz wählen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swap-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-lg w-100" @onclick="ProcessSwap" disabled="@(isProcessing || !CanSwap)">
|
||||||
|
@if (isProcessing)
|
||||||
|
{
|
||||||
|
<span class="spinner"></span>
|
||||||
|
}
|
||||||
|
<span>Reifenwechsel durchführen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
@* Search Results *@
|
@* Search Results *@
|
||||||
@if (searchResults.Count > 0)
|
@if (searchResults.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -182,7 +341,7 @@ else
|
|||||||
{
|
{
|
||||||
var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id);
|
var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id);
|
||||||
<div class="tireset-card @(isAlreadyScanned ? "already-scanned" : "")"
|
<div class="tireset-card @(isAlreadyScanned ? "already-scanned" : "")"
|
||||||
@onclick="() => AddToScannedList(ts)" role="listitem">
|
@onclick="() => OnResultClicked(ts)" role="listitem">
|
||||||
<div class="tireset-header">
|
<div class="tireset-header">
|
||||||
<span class="ticket-number">@ts.TicketNumber</span>
|
<span class="ticket-number">@ts.TicketNumber</span>
|
||||||
<span class="status-badge @ts.Status.ToString().ToLower()">
|
<span class="status-badge @ts.Status.ToString().ToLower()">
|
||||||
@@ -235,6 +394,74 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Storage location picker (rack view) for swap *@
|
||||||
|
@if (swapOutgoing != null)
|
||||||
|
{
|
||||||
|
<StorageLocationPickerModal @bind-IsOpen="showLocationPicker"
|
||||||
|
SelectedLocationId="@swapTargetLocation?.Id"
|
||||||
|
HighlightLocationId="@swapOutgoing.StorageLocationId"
|
||||||
|
Subtitle="@($"Einlagerung für {swapOutgoing.Customer?.FullName} – {SeasonLabel(swapUseHistory ? (swapSelectedHistory?.Season ?? swapNew.Season) : swapNew.Season)}")"
|
||||||
|
OnSelected="OnTargetLocationSelected" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Action Chooser Modal *@
|
||||||
|
@if (actionCandidate != null)
|
||||||
|
{
|
||||||
|
<div class="success-overlay" @onclick="CloseActionChooser">
|
||||||
|
<div class="success-modal action-modal" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="action-title">
|
||||||
|
<h2 id="action-title">Aktion wählen</h2>
|
||||||
|
<p class="action-subject">
|
||||||
|
<strong>@actionCandidate.Customer?.FullName</strong>
|
||||||
|
@if (actionCandidate.Vehicle != null)
|
||||||
|
{
|
||||||
|
<span class="vehicle-info">@actionCandidate.Vehicle.Make @actionCandidate.Vehicle.Model</span>
|
||||||
|
}
|
||||||
|
<br />
|
||||||
|
<span class="mono">@actionCandidate.TicketNumber</span> · @SeasonLabel(actionCandidate.Season) @(actionCandidate.TireSize != null ? $"· {actionCandidate.TireSize}" : "")
|
||||||
|
</p>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button type="button" class="action-btn" @onclick="ChoosePickup">
|
||||||
|
<span class="action-icon" aria-hidden="true">📤</span>
|
||||||
|
<span class="action-label">Abholen</span>
|
||||||
|
<span class="action-desc">Kunde nimmt die Reifen mit</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-btn swap" @onclick="ChooseSwap">
|
||||||
|
<span class="action-icon" aria-hidden="true">🔄</span>
|
||||||
|
<span class="action-label">Reifenwechsel</span>
|
||||||
|
<span class="action-desc">Satz montieren, anderen Satz einlagern</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline" @onclick="CloseActionChooser">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Swap Success Modal *@
|
||||||
|
@if (swapResult != null)
|
||||||
|
{
|
||||||
|
<div class="success-overlay" @onclick="CloseSwapSuccess">
|
||||||
|
<div class="success-modal" @onclick:stopPropagation="true">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Reifenwechsel erfolgreich!</h2>
|
||||||
|
<div class="pickup-list" role="list">
|
||||||
|
<div class="pickup-item" role="listitem">
|
||||||
|
<span class="ticket">⬆️ @swapResult.Outgoing.TicketNumber</span>
|
||||||
|
<span class="info">@SeasonLabel(swapResult.Outgoing.Season) montiert</span>
|
||||||
|
</div>
|
||||||
|
<div class="pickup-item" role="listitem">
|
||||||
|
<span class="ticket">⬇️ @swapResult.Incoming.TicketNumber</span>
|
||||||
|
<span class="info">@SeasonLabel(swapResult.Incoming.Season) eingelagert auf <strong>@swapResult.NewLocationCode</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (swapResult.FreedLocationCode != null && swapResult.FreedLocationCode != swapResult.NewLocationCode)
|
||||||
|
{
|
||||||
|
<p class="success-detail">Lagerplatz <strong>@swapResult.FreedLocationCode</strong> ist wieder frei.</p>
|
||||||
|
}
|
||||||
|
<button class="btn btn-primary" @onclick="CloseSwapSuccess">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Success Modal *@
|
@* Success Modal *@
|
||||||
@if (showSuccess)
|
@if (showSuccess)
|
||||||
{
|
{
|
||||||
@@ -297,6 +524,200 @@ else
|
|||||||
private CancellationTokenSource? _searchDebounceCts;
|
private CancellationTokenSource? _searchDebounceCts;
|
||||||
private readonly SemaphoreSlim _searchLock = new(1, 1);
|
private readonly SemaphoreSlim _searchLock = new(1, 1);
|
||||||
|
|
||||||
|
// Action chooser / swap flow
|
||||||
|
private TireStack? actionCandidate;
|
||||||
|
private TireStack? swapOutgoing;
|
||||||
|
private List<TireStack> swapHistory = new();
|
||||||
|
private TireStack? swapSelectedHistory;
|
||||||
|
private bool swapUseHistory;
|
||||||
|
private TireStack swapNew = new();
|
||||||
|
private bool showLocationPicker;
|
||||||
|
private StorageLocation? swapTargetLocation;
|
||||||
|
private Guid? swapTargetLocationId => swapTargetLocation?.Id;
|
||||||
|
private SwapResult? swapResult;
|
||||||
|
|
||||||
|
private bool CanSwap => swapOutgoing != null && (swapUseHistory ? swapSelectedHistory != null : !string.IsNullOrWhiteSpace(swapNew.TireSize));
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "stackId")]
|
||||||
|
public Guid? PreselectedStackId { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (!PreselectedStackId.HasValue || !UserSession.IsWorkerOrAdmin) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stack = await TireStackService.GetByIdAsync(PreselectedStackId.Value);
|
||||||
|
if (stack == null)
|
||||||
|
{
|
||||||
|
errorMessage = "Der gewählte Reifenstapel wurde nicht gefunden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults = new List<TireStack> { stack };
|
||||||
|
OnResultClicked(stack);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Reifenstapel konnte nicht geladen werden: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SeasonIcon(TireSeason s) => s == TireSeason.Sommer ? "☀️" : s == TireSeason.Winter ? "❄️" : "🔄";
|
||||||
|
private static string SeasonLabel(TireSeason s) => s == TireSeason.Sommer ? "☀️ Sommer" : s == TireSeason.Winter ? "❄️ Winter" : "🔄 Allrad";
|
||||||
|
|
||||||
|
private void OnResultClicked(TireStack ts)
|
||||||
|
{
|
||||||
|
if (ts.Status != TireStackStatus.Stored)
|
||||||
|
{
|
||||||
|
errorMessage = $"'{ts.TicketNumber}' ist nicht eingelagert";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory stacks and already selected stacks toggle directly
|
||||||
|
if (ts.IsInventory || scannedTireStacks.Any(s => s.Id == ts.Id))
|
||||||
|
{
|
||||||
|
AddToScannedList(ts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCandidate = ts;
|
||||||
|
errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseActionChooser() => actionCandidate = null;
|
||||||
|
|
||||||
|
private void ChoosePickup()
|
||||||
|
{
|
||||||
|
if (actionCandidate == null) return;
|
||||||
|
AddToScannedList(actionCandidate);
|
||||||
|
actionCandidate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ChooseSwap()
|
||||||
|
{
|
||||||
|
if (actionCandidate == null) return;
|
||||||
|
var ts = actionCandidate;
|
||||||
|
actionCandidate = null;
|
||||||
|
await StartSwap(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartSwap(TireStack outgoing)
|
||||||
|
{
|
||||||
|
swapOutgoing = outgoing;
|
||||||
|
swapResult = null;
|
||||||
|
showLocationPicker = false;
|
||||||
|
swapTargetLocation = outgoing.StorageLocation;
|
||||||
|
swapSelectedHistory = null;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
var oppositeSeason = outgoing.Season == TireSeason.Sommer ? TireSeason.Winter
|
||||||
|
: outgoing.Season == TireSeason.Winter ? TireSeason.Sommer
|
||||||
|
: TireSeason.Allrad;
|
||||||
|
|
||||||
|
swapNew = new TireStack
|
||||||
|
{
|
||||||
|
Season = oppositeSeason,
|
||||||
|
TireSize = outgoing.Vehicle?.TireSize ?? outgoing.TireSize,
|
||||||
|
WithRims = outgoing.WithRims,
|
||||||
|
Condition = TireCondition.Good,
|
||||||
|
CustomerId = outgoing.CustomerId,
|
||||||
|
VehicleId = outgoing.VehicleId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _searchLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var history = outgoing.CustomerId.HasValue
|
||||||
|
? await TireStackService.GetByCustomerAsync(outgoing.CustomerId.Value)
|
||||||
|
: new List<TireStack>();
|
||||||
|
|
||||||
|
swapHistory = history
|
||||||
|
.Where(h => h.Id != outgoing.Id && (h.Status == TireStackStatus.OnVehicle || h.Status == TireStackStatus.PickedUp))
|
||||||
|
.OrderBy(h => h.Season == outgoing.Season)
|
||||||
|
.ThenByDescending(h => h.PickedUpAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
swapHistory = new();
|
||||||
|
errorMessage = $"Historie konnte nicht geladen werden: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_searchLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
swapUseHistory = swapHistory.Count > 0;
|
||||||
|
swapSelectedHistory = swapHistory.FirstOrDefault(h => h.Season != outgoing.Season) ?? swapHistory.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTargetLocationSelected(StorageLocation loc)
|
||||||
|
{
|
||||||
|
swapTargetLocation = loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetTargetLocation()
|
||||||
|
{
|
||||||
|
swapTargetLocation = swapOutgoing?.StorageLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelSwap()
|
||||||
|
{
|
||||||
|
swapOutgoing = null;
|
||||||
|
swapHistory.Clear();
|
||||||
|
swapSelectedHistory = null;
|
||||||
|
showLocationPicker = false;
|
||||||
|
swapTargetLocation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSwap()
|
||||||
|
{
|
||||||
|
if (swapOutgoing == null || !CanSwap) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
errorMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
await _searchLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var incoming = swapUseHistory
|
||||||
|
? new TireStack
|
||||||
|
{
|
||||||
|
Id = swapSelectedHistory!.Id,
|
||||||
|
Condition = swapSelectedHistory.Condition,
|
||||||
|
Notes = swapSelectedHistory.Notes,
|
||||||
|
VehicleId = swapOutgoing.VehicleId
|
||||||
|
}
|
||||||
|
: swapNew;
|
||||||
|
|
||||||
|
var targetLocation = swapTargetLocationId == swapOutgoing.StorageLocationId ? null : swapTargetLocationId;
|
||||||
|
|
||||||
|
swapResult = await TireStackService.SwapAsync(
|
||||||
|
swapOutgoing.Id,
|
||||||
|
incoming,
|
||||||
|
targetLocation,
|
||||||
|
UserSession.CurrentUser?.Id);
|
||||||
|
|
||||||
|
CancelSwap();
|
||||||
|
searchResults.Clear();
|
||||||
|
searchTerm = "";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Fehler beim Reifenwechsel: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_searchLock.Release();
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSwapSuccess() => swapResult = null;
|
||||||
|
|
||||||
private async Task SearchTireStacks()
|
private async Task SearchTireStacks()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(searchTerm) || searchTerm.Length < 2)
|
if (string.IsNullOrWhiteSpace(searchTerm) || searchTerm.Length < 2)
|
||||||
@@ -435,10 +856,11 @@ else
|
|||||||
freedLocations.Clear();
|
freedLocations.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_searchDebounceCts?.Cancel();
|
_searchDebounceCts?.Cancel();
|
||||||
_searchDebounceCts?.Dispose();
|
_searchDebounceCts?.Dispose();
|
||||||
_searchLock.Dispose();
|
_searchLock.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,6 +712,363 @@
|
|||||||
margin-bottom: 1.5rem !important;
|
margin-bottom: 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Action chooser modal
|
||||||
|
======================================== */
|
||||||
|
.action-modal {
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-subject {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-subject .vehicle-info {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.swap:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-label {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Swap panel
|
||||||
|
======================================== */
|
||||||
|
.swap-panel {
|
||||||
|
background: rgba(28, 28, 30, 0.95);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.4);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.swap-columns {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-box {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-box.outgoing {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-box.incoming {
|
||||||
|
border-left: 3px solid #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-box-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-rows {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 36%) 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-row dt {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-tab.active {
|
||||||
|
background: rgba(96, 165, 250, 0.2);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-tab:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item.selected {
|
||||||
|
border-color: #22c55e;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item.same-season {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-season {
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-detail {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-warn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group .form-control {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group .form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-location-label {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-location-hint {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-location-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Responsive
|
Responsive
|
||||||
======================================== */
|
======================================== */
|
||||||
@@ -726,7 +1083,8 @@
|
|||||||
|
|
||||||
.checkout-card,
|
.checkout-card,
|
||||||
.results-section,
|
.results-section,
|
||||||
.scanned-section {
|
.scanned-section,
|
||||||
|
.swap-panel {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -738,7 +1096,8 @@
|
|||||||
|
|
||||||
.checkout-card,
|
.checkout-card,
|
||||||
.results-section,
|
.results-section,
|
||||||
.scanned-section {
|
.scanned-section,
|
||||||
|
.swap-panel {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,120 +40,101 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@* Statistics *@
|
@* Compact Toolbar: Stats as clickable filter chips + controls *@
|
||||||
<section class="stats-grid" aria-label="Lagerstatistik">
|
<section class="toolbar" aria-label="Statistik, Suche und Filter">
|
||||||
<article class="stat-card">
|
<div class="stat-chips" role="tablist" aria-label="Filter nach Typ">
|
||||||
<span class="stat-value" aria-label="Anzahl">@stats.TotalStacksStored</span>
|
<button class="stat-chip @(typeFilter == null ? "active" : "")"
|
||||||
<span class="stat-label">Stapel (@stats.TotalTiresStored Reifen)</span>
|
@onclick="() => SetTypeFilter(null)"
|
||||||
</article>
|
role="tab" aria-selected="@(typeFilter == null)"
|
||||||
<article class="stat-card">
|
title="Alle Stapel anzeigen">
|
||||||
<span class="stat-value" aria-label="Anzahl">@stats.CustomerStackCount</span>
|
<span class="chip-value">@stats.TotalStacksStored</span> Stapel (@stats.TotalTiresStored Reifen)
|
||||||
<span class="stat-label"><span aria-hidden="true">👤</span> Kundenstapel</span>
|
</button>
|
||||||
</article>
|
<button class="stat-chip customer @(typeFilter == false ? "active" : "")"
|
||||||
<article class="stat-card">
|
@onclick="() => SetTypeFilter(typeFilter == false ? null : false)"
|
||||||
<span class="stat-value" aria-label="Anzahl">@stats.InventoryStackCount</span>
|
role="tab" aria-selected="@(typeFilter == false)"
|
||||||
<span class="stat-label"><span aria-hidden="true">📦</span> Lagerbestand</span>
|
title="Nur Kundenstapel anzeigen">
|
||||||
</article>
|
<span class="chip-value">@stats.CustomerStackCount</span> <span aria-hidden="true">👤</span> Kunden
|
||||||
<article class="stat-card">
|
</button>
|
||||||
<span class="stat-value" aria-label="Anzahl">@stats.AvailableLocations</span>
|
<button class="stat-chip inventory @(typeFilter == true ? "active" : "")"
|
||||||
<span class="stat-label">Freie Plätze</span>
|
@onclick="() => SetTypeFilter(typeFilter == true ? null : true)"
|
||||||
</article>
|
role="tab" aria-selected="@(typeFilter == true)"
|
||||||
|
title="Nur Lagerbestand anzeigen">
|
||||||
|
<span class="chip-value">@stats.InventoryStackCount</span> <span aria-hidden="true">📦</span> Lager
|
||||||
|
</button>
|
||||||
|
<span class="stat-chip static" title="Freie Lagerplätze">
|
||||||
|
<span class="chip-value free">@stats.AvailableLocations</span> Frei
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-controls">
|
||||||
|
<div class="search-box">
|
||||||
|
<label for="search-input" class="visually-hidden">Suche</label>
|
||||||
|
<input type="search"
|
||||||
|
id="search-input"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="🔍 Suchen (Name, Fahrzeug, Ticket-Nr.)..."
|
||||||
|
@bind="searchTerm"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeyup="FilterTireStacks"
|
||||||
|
aria-describedby="search-hint" />
|
||||||
|
<span id="search-hint" class="visually-hidden">Durchsucht Namen, Fahrzeuge, Kategorien und Ticket-Nummern</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="filter-buttons">
|
||||||
|
<legend class="visually-hidden">Nach Saison filtern</legend>
|
||||||
|
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
|
||||||
|
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Sommer ? null : TireSeason.Sommer)"
|
||||||
|
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
|
||||||
|
aria-label="Sommerreifen" title="Sommerreifen">
|
||||||
|
<span aria-hidden="true">☀️</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
|
||||||
|
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Winter ? null : TireSeason.Winter)"
|
||||||
|
aria-pressed="@(seasonFilter == TireSeason.Winter)"
|
||||||
|
aria-label="Winterreifen" title="Winterreifen">
|
||||||
|
<span aria-hidden="true">❄️</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
|
||||||
|
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Allrad ? null : TireSeason.Allrad)"
|
||||||
|
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
|
||||||
|
aria-label="Ganzjahresreifen" title="Ganzjahresreifen">
|
||||||
|
<span aria-hidden="true">🔄</span>
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
@if (typeFilter == true && categories.Count > 0)
|
||||||
|
{
|
||||||
|
<select id="category-select" class="form-control category-select" @bind="selectedCategoryId" @bind:after="FilterTireStacks" title="Kategorie auswählen">
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
@foreach (var cat in categories)
|
||||||
|
{
|
||||||
|
<option value="@cat.Id">@cat.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="view-toggle" role="tablist" aria-label="Ansicht wählen">
|
||||||
|
<button class="view-btn @(showGridView ? "" : "active")"
|
||||||
|
@onclick="() => showGridView = false"
|
||||||
|
role="tab" aria-selected="@(!showGridView)" title="Listenansicht">
|
||||||
|
<span aria-hidden="true">☰</span> Liste
|
||||||
|
</button>
|
||||||
|
<button class="view-btn @(showGridView ? "active" : "")"
|
||||||
|
@onclick="() => showGridView = true"
|
||||||
|
role="tab" aria-selected="@showGridView" title="Regalansicht">
|
||||||
|
<span aria-hidden="true">▦</span> Regal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (HasActiveFilters)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline btn-sm clear-filters" @onclick="ResetFilters" title="Filter zurücksetzen">
|
||||||
|
✕ Filter löschen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@* Type Filter (Customer vs Inventory) *@
|
|
||||||
<nav class="type-filter" role="tablist" aria-label="Filter nach Typ">
|
|
||||||
<button class="type-btn @(typeFilter == null ? "active" : "")"
|
|
||||||
@onclick="() => SetTypeFilter(null)"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="@(typeFilter == null)">
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
<button class="type-btn @(typeFilter == false ? "active" : "")"
|
|
||||||
@onclick="() => SetTypeFilter(false)"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="@(typeFilter == false)">
|
|
||||||
<span aria-hidden="true">👤</span> Kundenstapel
|
|
||||||
</button>
|
|
||||||
<button class="type-btn @(typeFilter == true ? "active" : "")"
|
|
||||||
@onclick="() => SetTypeFilter(true)"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="@(typeFilter == true)">
|
|
||||||
<span aria-hidden="true">📦</span> Lagerbestand
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
@* View Toggle (List / Rack Grid) *@
|
|
||||||
<nav class="view-toggle" role="tablist" aria-label="Ansicht wählen">
|
|
||||||
<button class="view-btn @(showGridView ? "" : "active")"
|
|
||||||
@onclick="() => showGridView = false"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="@(!showGridView)">
|
|
||||||
<span aria-hidden="true">☰</span> Liste
|
|
||||||
</button>
|
|
||||||
<button class="view-btn @(showGridView ? "active" : "")"
|
|
||||||
@onclick="() => showGridView = true"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="@showGridView">
|
|
||||||
<span aria-hidden="true">▦</span> Regalansicht
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
@* Search & Filter *@
|
|
||||||
<section class="filter-bar" aria-label="Suche und Filter">
|
|
||||||
<label for="search-input" class="visually-hidden">Suche</label>
|
|
||||||
<input type="search"
|
|
||||||
id="search-input"
|
|
||||||
class="search-input"
|
|
||||||
placeholder="Suchen (Name, Fahrzeug, Kategorie, Ticket-Nr.)..."
|
|
||||||
@bind="searchTerm"
|
|
||||||
@bind:event="oninput"
|
|
||||||
@onkeyup="FilterTireStacks"
|
|
||||||
aria-describedby="search-hint" />
|
|
||||||
<span id="search-hint" class="visually-hidden">Durchsucht Namen, Fahrzeuge, Kategorien und Ticket-Nummern</span>
|
|
||||||
|
|
||||||
<fieldset class="filter-buttons">
|
|
||||||
<legend class="visually-hidden">Nach Saison filtern</legend>
|
|
||||||
<button class="filter-btn @(seasonFilter == null ? "active" : "")"
|
|
||||||
@onclick="() => SetSeasonFilter(null)"
|
|
||||||
aria-pressed="@(seasonFilter == null)">
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
|
|
||||||
@onclick="() => SetSeasonFilter(TireSeason.Sommer)"
|
|
||||||
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
|
|
||||||
aria-label="Sommerreifen">
|
|
||||||
<span aria-hidden="true">☀️</span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
|
|
||||||
@onclick="() => SetSeasonFilter(TireSeason.Winter)"
|
|
||||||
aria-pressed="@(seasonFilter == TireSeason.Winter)"
|
|
||||||
aria-label="Winterreifen">
|
|
||||||
<span aria-hidden="true">❄️</span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
|
|
||||||
@onclick="() => SetSeasonFilter(TireSeason.Allrad)"
|
|
||||||
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
|
|
||||||
aria-label="Ganzjahresreifen">
|
|
||||||
<span aria-hidden="true">🔄</span>
|
|
||||||
</button>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Category Filter (only when showing inventory) *@
|
|
||||||
@if (typeFilter == true && categories.Count > 0)
|
|
||||||
{
|
|
||||||
<section class="category-filter">
|
|
||||||
<label for="category-select" class="visually-hidden">Kategorie auswählen</label>
|
|
||||||
<select id="category-select" class="form-control" @bind="selectedCategoryId" @bind:after="FilterTireStacks">
|
|
||||||
<option value="">-- Alle Kategorien --</option>
|
|
||||||
@foreach (var cat in categories)
|
|
||||||
{
|
|
||||||
<option value="@cat.Id">@cat.Name</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Tire Stacks List *@
|
@* Tire Stacks List *@
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
@@ -224,24 +205,22 @@ else
|
|||||||
@if (selectedLocation.IsOccupied && selectedLocation.OccupiedTireStack != null)
|
@if (selectedLocation.IsOccupied && selectedLocation.OccupiedTireStack != null)
|
||||||
{
|
{
|
||||||
var stack = selectedLocation.OccupiedTireStack;
|
var stack = selectedLocation.OccupiedTireStack;
|
||||||
<dl class="detail-grid">
|
@StackDetailLists(stack)
|
||||||
<div class="detail-item">
|
@if (!string.IsNullOrEmpty(stack.Notes))
|
||||||
<dt class="label">Ticket-Nr.</dt>
|
{
|
||||||
<dd class="value">@stack.TicketNumber</dd>
|
<div class="notes-section">
|
||||||
|
<span class="label">Notizen:</span>
|
||||||
|
<span class="notes-text">@stack.Notes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
}
|
||||||
<dt class="label">@(stack.IsInventory ? "Kategorie" : "Kunde")</dt>
|
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
|
||||||
<dd class="value">@(stack.IsInventory ? stack.CatalogItem?.Name ?? "Lagerbestand" : stack.Customer?.FullName ?? "-")</dd>
|
<button type="button" class="btn btn-sm btn-primary" @onclick="() => OpenEditModalFor(stack)">✏️ Bearbeiten</button>
|
||||||
</div>
|
@if (stack.CustomerId.HasValue)
|
||||||
<div class="detail-item">
|
{
|
||||||
<dt class="label">Reifen</dt>
|
<a href="@CheckinUrl(stack)" class="btn btn-sm btn-outline">+ Einlagern</a>
|
||||||
<dd class="value">@(stack.TireSize ?? "-") @(stack.WithRims ? "| mit Felgen" : "")</dd>
|
}
|
||||||
</div>
|
<a href="@CheckoutUrl(stack)" class="btn btn-sm btn-outline">@(stack.IsInventory ? "Entnehmen" : "Abholen")</a>
|
||||||
<div class="detail-item">
|
</nav>
|
||||||
<dt class="label">Eingelagert</dt>
|
|
||||||
<dd class="value">@stack.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
}
|
}
|
||||||
else if (!selectedLocation.IsAvailable)
|
else if (!selectedLocation.IsAvailable)
|
||||||
{
|
{
|
||||||
@@ -270,110 +249,171 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@if (selectedStack != null)
|
||||||
|
{
|
||||||
|
<aside class="stack-details-panel" aria-label="Details zum gewählten Reifenstapel">
|
||||||
|
<header class="stack-details-header">
|
||||||
|
<div class="stack-details-title">
|
||||||
|
@if (selectedStack.IsInventory)
|
||||||
|
{
|
||||||
|
<h2><span aria-hidden="true">📦</span> @(selectedStack.CatalogItem?.Name ?? "Lagerbestand")</h2>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h2>@selectedStack.Customer?.FullName</h2>
|
||||||
|
@if (selectedStack.Vehicle != null)
|
||||||
|
{
|
||||||
|
<span class="vehicle-badge">@selectedStack.Vehicle.Make @selectedStack.Vehicle.Model</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="stack-details-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="OpenEditModal">✏️ Bearbeiten</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @onclick="() => selectedStack = null">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
@StackDetailLists(selectedStack)
|
||||||
|
@if (!string.IsNullOrEmpty(selectedStack.Notes))
|
||||||
|
{
|
||||||
|
<div class="notes-section">
|
||||||
|
<span class="label">Notizen:</span>
|
||||||
|
<span class="notes-text">@selectedStack.Notes</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
|
||||||
|
@if (selectedStack.CustomerId.HasValue)
|
||||||
|
{
|
||||||
|
<a href="@CheckinUrl(selectedStack)" class="btn btn-sm btn-outline">+ Einlagern</a>
|
||||||
|
}
|
||||||
|
<a href="@CheckoutUrl(selectedStack)" class="btn btn-sm btn-primary">@(selectedStack.IsInventory ? "Entnehmen" : "Abholen")</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
<section class="tireset-list" aria-label="Liste der Reifenstapel" role="list">
|
<section class="tireset-list" aria-label="Liste der Reifenstapel" role="list">
|
||||||
@foreach (var ts in filteredTireStacks)
|
@foreach (var ts in filteredTireStacks)
|
||||||
{
|
{
|
||||||
<article class="tireset-card @(ts.IsInventory ? "inventory" : "")"
|
<article class="tireset-card @(ts.IsInventory ? "inventory" : "") @(selectedStack?.Id == ts.Id ? "selected" : "")"
|
||||||
@onclick="() => ToggleDetails(ts.Id)"
|
@onclick="() => SelectStack(ts)"
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-expanded="@(expandedId == ts.Id)">
|
aria-selected="@(selectedStack?.Id == ts.Id)">
|
||||||
<div class="tireset-main">
|
<div class="card-row card-row-primary">
|
||||||
<div class="tireset-left">
|
<div class="card-identity">
|
||||||
<div class="season-indicator @ts.Season.ToString().ToLower()"
|
|
||||||
aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
|
|
||||||
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
|
|
||||||
</div>
|
|
||||||
<div class="tireset-info">
|
|
||||||
@if (ts.IsInventory)
|
|
||||||
{
|
|
||||||
<span class="customer-name inventory-name">
|
|
||||||
<span aria-hidden="true">📦</span> @(ts.CatalogItem?.Name ?? "Lagerbestand")
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="customer-name">@ts.Customer?.FullName</span>
|
|
||||||
}
|
|
||||||
<span class="tire-details">1 Stapel (@TireStack.TiresPerStack Reifen) @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.WithRims ? "| mit Felgen" : "")</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tireset-right">
|
|
||||||
<span class="location-code">@(ts.StorageLocation?.Code ?? "-")</span>
|
|
||||||
@if (!ts.IsInventory && ts.Vehicle != null)
|
|
||||||
{
|
|
||||||
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
|
|
||||||
}
|
|
||||||
@if (ts.IsInventory)
|
@if (ts.IsInventory)
|
||||||
{
|
{
|
||||||
|
<span class="customer-name inventory-name">
|
||||||
|
<span aria-hidden="true">📦</span> @(ts.CatalogItem?.Name ?? "Lagerbestand")
|
||||||
|
</span>
|
||||||
<span class="inventory-badge">Lager</span>
|
<span class="inventory-badge">Lager</span>
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="customer-name">@ts.Customer?.FullName</span>
|
||||||
|
@if (ts.Vehicle != null)
|
||||||
|
{
|
||||||
|
<span class="vehicle-badge">@ts.Vehicle.Make @ts.Vehicle.Model</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-location">
|
||||||
|
@if (ts.StorageLocation != null)
|
||||||
|
{
|
||||||
|
<span class="location-indicator">
|
||||||
|
<span class="loc-area @(ts.StorageLocation.Area == AreaType.Werkstatt ? "werkstatt" : "lager")">@ts.StorageLocation.Area.GetDisplayName()</span>
|
||||||
|
<span class="loc-part">Regal @ts.StorageLocation.Row</span>
|
||||||
|
<span class="loc-part">@ts.StorageLocation.Position.GetShortName()</span>
|
||||||
|
<span class="loc-part">P@(ts.StorageLocation.SlotNumber)</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="location-code">-</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-row card-row-secondary">
|
||||||
@if (expandedId == ts.Id)
|
<span class="season-indicator @ts.Season.ToString().ToLower()"
|
||||||
{
|
aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
|
||||||
<div class="tireset-details">
|
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
|
||||||
<dl class="detail-grid">
|
</span>
|
||||||
<div class="detail-item">
|
<span class="tire-details">@ts.ItemCount Reifen @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.TireModel != null ? $"| {ts.TireModel}" : "") @(ts.WithRims ? "| mit Felgen" : "")</span>
|
||||||
<dt class="label">Ticket-Nr.</dt>
|
</div>
|
||||||
<dd class="value">@ts.TicketNumber</dd>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Reifenstapel</dt>
|
|
||||||
<dd class="value">1 Stapel (@TireStack.TiresPerStack Reifen)</dd>
|
|
||||||
</div>
|
|
||||||
@if (ts.IsInventory && ts.CatalogItem != null)
|
|
||||||
{
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Kategorie</dt>
|
|
||||||
<dd class="value">@ts.CatalogItem.Name</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Marke</dt>
|
|
||||||
<dd class="value">@(ts.TireBrand ?? "-")</dd>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Modell</dt>
|
|
||||||
<dd class="value">@(ts.TireModel ?? "-")</dd>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Zustand</dt>
|
|
||||||
<dd class="value">@ts.ConditionText</dd>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Eingelagert</dt>
|
|
||||||
<dd class="value"><time datetime="@ts.StoredAt.ToString("yyyy-MM-dd")">@ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<dt class="label">Dauer</dt>
|
|
||||||
<dd class="value">@ts.DaysStored Tage</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
@if (!string.IsNullOrEmpty(ts.Notes))
|
|
||||||
{
|
|
||||||
<div class="notes-section">
|
|
||||||
<span class="label">Notizen:</span>
|
|
||||||
<span class="notes-text">@ts.Notes</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
|
|
||||||
@if (!ts.IsInventory)
|
|
||||||
{
|
|
||||||
<a href="/tire-checkout" class="btn btn-sm btn-primary">Abholen</a>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<a href="/tire-checkout" class="btn btn-sm btn-primary">Entnehmen</a>
|
|
||||||
}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (showEditModal && editStack != null)
|
||||||
|
{
|
||||||
|
<div class="modal-overlay" @onclick="CloseEditModal">
|
||||||
|
<div class="modal-dialog" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="edit-modal-title">Reifenstapel bearbeiten – @editStack.TicketNumber</h2>
|
||||||
|
<button type="button" class="modal-close" @onclick="CloseEditModal" aria-label="Schließen">✕</button>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-size">Reifengröße</label>
|
||||||
|
<input id="edit-size" class="form-control" @bind="editStack.TireSize" placeholder="z.B. 225/45R17" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-brand">Marke</label>
|
||||||
|
<input id="edit-brand" class="form-control" @bind="editStack.TireBrand" placeholder="z.B. Michelin" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-model">Modell</label>
|
||||||
|
<input id="edit-model" class="form-control" @bind="editStack.TireModel" placeholder="z.B. Pilot Sport 4" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-count">Anzahl</label>
|
||||||
|
<input id="edit-count" type="number" min="1" max="8" class="form-control" @bind="editStack.ItemCount" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-season">Saison</label>
|
||||||
|
<select id="edit-season" class="form-control" @bind="editStack.Season">
|
||||||
|
<option value="@TireSeason.Sommer">☀️ Sommer</option>
|
||||||
|
<option value="@TireSeason.Winter">❄️ Winter</option>
|
||||||
|
<option value="@TireSeason.Allrad">🔄 Allrad</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-condition">Zustand</label>
|
||||||
|
<select id="edit-condition" class="form-control" @bind="editStack.Condition">
|
||||||
|
<option value="@TireCondition.Excellent">Sehr gut</option>
|
||||||
|
<option value="@TireCondition.Good">Gut</option>
|
||||||
|
<option value="@TireCondition.Fair">Ausreichend</option>
|
||||||
|
<option value="@TireCondition.Poor">Mangelhaft</option>
|
||||||
|
<option value="@TireCondition.Critical">Kritisch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-rims">Felgen</label>
|
||||||
|
<select id="edit-rims" class="form-control" @bind="editStack.WithRims">
|
||||||
|
<option value="true">Mit Felgen</option>
|
||||||
|
<option value="false">Ohne Felgen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-wide">
|
||||||
|
<label for="edit-notes">Notizen</label>
|
||||||
|
<textarea id="edit-notes" class="form-control" rows="3" @bind="editStack.Notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(editError))
|
||||||
|
{
|
||||||
|
<p class="edit-error" role="alert">@editError</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<footer class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" @onclick="CloseEditModal" disabled="@isSaving">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveEdit" disabled="@isSaving">
|
||||||
|
@(isSaving ? "Speichern..." : "Speichern")
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Expiring Pickups Warning (only for customer stacks) *@
|
@* Expiring Pickups Warning (only for customer stacks) *@
|
||||||
@if (expiringPickups.Count > 0)
|
@if (expiringPickups.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -394,6 +434,136 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private static string CheckinUrl(TireStack stack) => $"/tire-checkin?customerId={stack.CustomerId}";
|
||||||
|
private static string CheckoutUrl(TireStack stack) => $"/tire-checkout?stackId={stack.Id}";
|
||||||
|
|
||||||
|
private RenderFragment<TireStack> StackDetailLists => stack => __builder =>
|
||||||
|
{
|
||||||
|
<div class="detail-columns">
|
||||||
|
<section class="detail-list" aria-labelledby="detail-list-customer">
|
||||||
|
<h3 id="detail-list-customer" class="detail-list-title"><span aria-hidden="true">👤</span> @(stack.IsInventory ? "Bestand & Lagerplatz" : "Kunde & Lagerplatz")</h3>
|
||||||
|
<dl class="detail-rows">
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Ticket-Nr.</dt>
|
||||||
|
<dd class="mono">@stack.TicketNumber</dd>
|
||||||
|
</div>
|
||||||
|
@if (stack.IsInventory)
|
||||||
|
{
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Kategorie</dt>
|
||||||
|
<dd>@(stack.CatalogItem?.Name ?? "Lagerbestand")</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Kunde</dt>
|
||||||
|
<dd>@(stack.Customer?.FullName ?? "-")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Kundennr.</dt>
|
||||||
|
<dd class="mono">@(stack.Customer?.CustomerNumber ?? "-")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Telefon</dt>
|
||||||
|
<dd>@(stack.Customer?.Phone ?? "-")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Fahrzeug</dt>
|
||||||
|
<dd>@(stack.Vehicle != null ? $"{stack.Vehicle.Make} {stack.Vehicle.Model}".Trim() : "-")</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Lagerplatz</dt>
|
||||||
|
<dd>
|
||||||
|
@if (stack.StorageLocation != null)
|
||||||
|
{
|
||||||
|
<span class="location-indicator">
|
||||||
|
<span class="loc-area @(stack.StorageLocation.Area == AreaType.Werkstatt ? "werkstatt" : "lager")">@stack.StorageLocation.Area.GetDisplayName()</span>
|
||||||
|
<span class="loc-part">Regal @stack.StorageLocation.Row</span>
|
||||||
|
<span class="loc-part">@stack.StorageLocation.Position.GetShortName()</span>
|
||||||
|
<span class="loc-part">P@(stack.StorageLocation.SlotNumber)</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>-</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Code</dt>
|
||||||
|
<dd class="mono">@(stack.StorageLocation?.Code ?? "-")</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-list" aria-labelledby="detail-list-tires">
|
||||||
|
<h3 id="detail-list-tires" class="detail-list-title"><span aria-hidden="true">🛞</span> Reifen & Lagerung</h3>
|
||||||
|
<dl class="detail-rows">
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Anzahl</dt>
|
||||||
|
<dd>@stack.ItemCount Reifen</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Größe</dt>
|
||||||
|
<dd class="mono">@(stack.TireSize ?? "-")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Marke / Modell</dt>
|
||||||
|
<dd>@(string.Join(" ", new[] { stack.TireBrand, stack.TireModel }.Where(s => !string.IsNullOrWhiteSpace(s))) is { Length: > 0 } bm ? bm : "-")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Saison</dt>
|
||||||
|
<dd>@(stack.Season == TireSeason.Sommer ? "☀️ Sommer" : stack.Season == TireSeason.Winter ? "❄️ Winter" : "🔄 Allrad")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Felgen</dt>
|
||||||
|
<dd>@(stack.WithRims ? "Mit Felgen" : "Ohne Felgen")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Zustand</dt>
|
||||||
|
<dd>@stack.ConditionText</dd>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Eingelagert</dt>
|
||||||
|
<dd><time datetime="@stack.StoredAt.ToString("yyyy-MM-dd")">@stack.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</time> <span class="muted">(@stack.DaysStored Tage)</span></dd>
|
||||||
|
</div>
|
||||||
|
@if (stack.ExpectedPickupDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Abholung erwartet</dt>
|
||||||
|
<dd><time datetime="@stack.ExpectedPickupDate.Value.ToString("yyyy-MM-dd")">@stack.ExpectedPickupDate.Value.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (latestInspections.TryGetValue(stack.Id, out var insp) && insp != null)
|
||||||
|
{
|
||||||
|
<div class="detail-row">
|
||||||
|
<dt>Letzte Messung</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="tread-chips">
|
||||||
|
<span class="tread-chip rating-@RatingClass(insp.RatingFL)">VL @FmtMm(insp.TreadDepthFL)</span>
|
||||||
|
<span class="tread-chip rating-@RatingClass(insp.RatingFR)">VR @FmtMm(insp.TreadDepthFR)</span>
|
||||||
|
<span class="tread-chip rating-@RatingClass(insp.RatingRL)">HL @FmtMm(insp.TreadDepthRL)</span>
|
||||||
|
<span class="tread-chip rating-@RatingClass(insp.RatingRR)">HR @FmtMm(insp.TreadDepthRR)</span>
|
||||||
|
</div>
|
||||||
|
<span class="muted">
|
||||||
|
@((insp.MeasuredAt ?? insp.CreatedAt).ToLocalTime().ToString("dd.MM.yyyy"))
|
||||||
|
@if (insp.Mileage.HasValue) { <text> · @insp.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km</text> }
|
||||||
|
@if (!string.IsNullOrEmpty(insp.LicensePlate)) { <text> · @insp.LicensePlate</text> }
|
||||||
|
</span>
|
||||||
|
@if (!string.IsNullOrEmpty(insp.ReportUrl))
|
||||||
|
{
|
||||||
|
<a class="report-link" href="@insp.ReportUrl" target="_blank" rel="noopener">Report öffnen ↗</a>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
private List<TireStack> allTireStacks = new();
|
private List<TireStack> allTireStacks = new();
|
||||||
private List<TireStack> filteredTireStacks = new();
|
private List<TireStack> filteredTireStacks = new();
|
||||||
private List<TireStack> expiringPickups = new();
|
private List<TireStack> expiringPickups = new();
|
||||||
@@ -404,7 +574,36 @@ else
|
|||||||
private TireSeason? seasonFilter;
|
private TireSeason? seasonFilter;
|
||||||
private bool? typeFilter; // null = all, false = customer, true = inventory
|
private bool? typeFilter; // null = all, false = customer, true = inventory
|
||||||
private string selectedCategoryId = "";
|
private string selectedCategoryId = "";
|
||||||
private Guid? expandedId;
|
private TireStack? selectedStack;
|
||||||
|
private readonly Dictionary<Guid, TireInspection?> latestInspections = new();
|
||||||
|
|
||||||
|
private static string RatingClass(TireMeasureRating r) => r switch
|
||||||
|
{
|
||||||
|
TireMeasureRating.Ok => "ok",
|
||||||
|
TireMeasureRating.Warning => "warn",
|
||||||
|
TireMeasureRating.Critical => "crit",
|
||||||
|
_ => "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FmtMm(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
|
||||||
|
|
||||||
|
private async Task LoadLatestInspection(TireStack ts)
|
||||||
|
{
|
||||||
|
if (latestInspections.ContainsKey(ts.Id)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
latestInspections[ts.Id] = await TireStackService.GetLatestInspectionAsync(ts.Id);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
latestInspections[ts.Id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private TireStack? editStack;
|
||||||
|
private TireStack? editSource;
|
||||||
|
private bool showEditModal;
|
||||||
|
private bool isSaving;
|
||||||
|
private string? editError;
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private bool showGridView;
|
private bool showGridView;
|
||||||
private List<StorageLocation> allLocations = new();
|
private List<StorageLocation> allLocations = new();
|
||||||
@@ -487,6 +686,19 @@ else
|
|||||||
ApplyFilters();
|
ApplyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool HasActiveFilters =>
|
||||||
|
typeFilter != null || seasonFilter != null ||
|
||||||
|
!string.IsNullOrEmpty(selectedCategoryId) || !string.IsNullOrWhiteSpace(searchTerm);
|
||||||
|
|
||||||
|
private void ResetFilters()
|
||||||
|
{
|
||||||
|
typeFilter = null;
|
||||||
|
seasonFilter = null;
|
||||||
|
selectedCategoryId = "";
|
||||||
|
searchTerm = "";
|
||||||
|
ApplyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyFilters()
|
private void ApplyFilters()
|
||||||
{
|
{
|
||||||
var query = allTireStacks.AsEnumerable();
|
var query = allTireStacks.AsEnumerable();
|
||||||
@@ -532,9 +744,86 @@ else
|
|||||||
filteredTireStacks = query.OrderBy(ts => ts.StorageLocation?.Code).ToList();
|
filteredTireStacks = query.OrderBy(ts => ts.StorageLocation?.Code).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleDetails(Guid id)
|
private async Task SelectStack(TireStack ts)
|
||||||
{
|
{
|
||||||
expandedId = expandedId == id ? null : id;
|
selectedStack = selectedStack?.Id == ts.Id ? null : ts;
|
||||||
|
if (selectedStack != null)
|
||||||
|
{
|
||||||
|
await LoadLatestInspection(selectedStack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditModal()
|
||||||
|
{
|
||||||
|
if (selectedStack == null) return;
|
||||||
|
OpenEditModalFor(selectedStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditModalFor(TireStack source)
|
||||||
|
{
|
||||||
|
editSource = source;
|
||||||
|
editStack = new TireStack
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
TireSize = source.TireSize,
|
||||||
|
TireBrand = source.TireBrand,
|
||||||
|
TireModel = source.TireModel,
|
||||||
|
ItemCount = source.ItemCount,
|
||||||
|
Season = source.Season,
|
||||||
|
Condition = source.Condition,
|
||||||
|
WithRims = source.WithRims,
|
||||||
|
Notes = source.Notes,
|
||||||
|
TicketNumber = source.TicketNumber
|
||||||
|
};
|
||||||
|
editError = null;
|
||||||
|
showEditModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseEditModal()
|
||||||
|
{
|
||||||
|
if (isSaving) return;
|
||||||
|
showEditModal = false;
|
||||||
|
editStack = null;
|
||||||
|
editSource = null;
|
||||||
|
editError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveEdit()
|
||||||
|
{
|
||||||
|
if (editStack == null || editSource == null) return;
|
||||||
|
isSaving = true;
|
||||||
|
editError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
editSource.TireSize = editStack.TireSize;
|
||||||
|
editSource.TireBrand = editStack.TireBrand;
|
||||||
|
editSource.TireModel = editStack.TireModel;
|
||||||
|
editSource.ItemCount = editStack.ItemCount;
|
||||||
|
editSource.Season = editStack.Season;
|
||||||
|
editSource.Condition = editStack.Condition;
|
||||||
|
editSource.WithRims = editStack.WithRims;
|
||||||
|
editSource.Notes = editStack.Notes;
|
||||||
|
|
||||||
|
await TireStackService.UpdateAsync(editSource);
|
||||||
|
|
||||||
|
showEditModal = false;
|
||||||
|
editStack = null;
|
||||||
|
editSource = null;
|
||||||
|
var selectedId = selectedStack?.Id;
|
||||||
|
await LoadData();
|
||||||
|
if (selectedId.HasValue)
|
||||||
|
{
|
||||||
|
selectedStack = allTireStacks.FirstOrDefault(ts => ts.Id == selectedId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
editError = $"Fehler beim Speichern: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
|
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
|
||||||
@@ -542,10 +831,14 @@ else
|
|||||||
return locationLookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
|
return locationLookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectLocation(StorageLocation? location)
|
private async Task SelectLocation(StorageLocation? location)
|
||||||
{
|
{
|
||||||
if (location == null) return;
|
if (location == null) return;
|
||||||
selectedLocation = selectedLocation?.Id == location.Id ? null : location;
|
selectedLocation = selectedLocation?.Id == location.Id ? null : location;
|
||||||
|
if (selectedLocation?.OccupiedTireStack != null)
|
||||||
|
{
|
||||||
|
await LoadLatestInspection(selectedLocation.OccupiedTireStack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetCellTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot)
|
private string GetCellTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot)
|
||||||
|
|||||||
@@ -34,12 +34,12 @@
|
|||||||
Page Header - Responsive
|
Page Header - Responsive
|
||||||
======================================== */
|
======================================== */
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,193 +85,145 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Statistics Grid - Responsive
|
Compact Toolbar (Stats + Filter combined)
|
||||||
======================================== */
|
======================================== */
|
||||||
.stats-grid {
|
.toolbar {
|
||||||
display: grid;
|
position: sticky;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
top: 0;
|
||||||
gap: 8px;
|
z-index: 10;
|
||||||
margin-bottom: 1rem;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
@media (min-width: 480px) {
|
margin-bottom: 0.75rem;
|
||||||
.stats-grid {
|
padding: 0.6rem 0.75rem;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
background: rgba(28, 28, 30, 0.92);
|
||||||
gap: 10px;
|
backdrop-filter: blur(12px);
|
||||||
margin-bottom: 1.5rem;
|
-webkit-backdrop-filter: blur(12px);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.stats-grid {
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.stats-grid {
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: rgba(28, 28, 30, 0.8);
|
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.stat-card {
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.stat-card {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.stat-card {
|
.toolbar {
|
||||||
padding: 24px;
|
flex-direction: row;
|
||||||
border-radius: 16px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
/* Stat chips - clickable, act as filters */
|
||||||
display: block;
|
.stat-chips {
|
||||||
font-family: 'Orbitron', monospace;
|
|
||||||
font-size: clamp(1.25rem, 4vw, 2.25rem);
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value.summer {
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value.winter {
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-family: 'Rajdhani', sans-serif;
|
|
||||||
font-size: clamp(0.65rem, 2vw, 0.85rem);
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Type Filter - Responsive
|
|
||||||
======================================== */
|
|
||||||
.type-filter {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
background: rgba(28, 28, 30, 0.6);
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.type-filter {
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-family: 'Rajdhani', sans-serif;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.type-btn {
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-btn:hover {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-btn.active {
|
|
||||||
background: rgba(220, 38, 38, 0.15);
|
|
||||||
border: 1px solid rgba(220, 38, 38, 0.3);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Filter Bar - Responsive
|
|
||||||
======================================== */
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
.stat-chip {
|
||||||
.filter-bar {
|
display: inline-flex;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
gap: 6px;
|
||||||
}
|
padding: 5px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.stat-chip.static {
|
||||||
.filter-bar {
|
cursor: default;
|
||||||
gap: 14px;
|
}
|
||||||
}
|
|
||||||
|
.stat-chip:not(.static):hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip .chip-value {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip .chip-value.free {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.active {
|
||||||
|
background: rgba(220, 38, 38, 0.15);
|
||||||
|
border-color: rgba(220, 38, 38, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.customer.active {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip.inventory.active {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
border-color: rgba(251, 191, 36, 0.4);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar controls: search + season + category + view toggle */
|
||||||
|
.toolbar-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .clear-filters {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-height: 34px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 0.5px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #f87171;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls .clear-filters:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 7px 12px;
|
||||||
background: rgba(28, 28, 30, 0.8);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
font-size: 16px; /* Prevents iOS zoom */
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.search-input {
|
.search-input {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
border-radius: 12px;
|
font-size: 0.9rem;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.search-input {
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,18 +240,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
border: none;
|
||||||
|
margin: 0;
|
||||||
@media (max-width: 479.98px) {
|
padding: 0;
|
||||||
.filter-buttons {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 10px 12px;
|
padding: 5px 10px;
|
||||||
background: rgba(28, 28, 30, 0.8);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
@@ -307,15 +255,7 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-height: 44px;
|
min-height: 34px;
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.filter-btn {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn:hover {
|
||||||
@@ -877,41 +817,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Category Filter - Responsive
|
Category Select (in Toolbar)
|
||||||
======================================== */
|
======================================== */
|
||||||
.category-filter {
|
.category-select {
|
||||||
margin-bottom: 0.75rem;
|
width: auto;
|
||||||
}
|
min-width: 140px;
|
||||||
|
padding: 6px 10px;
|
||||||
@media (min-width: 480px) {
|
background: rgba(0, 0, 0, 0.3);
|
||||||
.category-filter {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-filter .form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: rgba(28, 28, 30, 0.8);
|
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
font-size: 0.8rem;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
.category-select:focus {
|
||||||
.category-filter .form-control {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-filter .form-control:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(220, 38, 38, 0.5);
|
border-color: rgba(220, 38, 38, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-filter .form-control option {
|
.category-select option {
|
||||||
background: #1c1c1e;
|
background: #1c1c1e;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
@@ -921,26 +847,26 @@
|
|||||||
======================================== */
|
======================================== */
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
margin-bottom: 0.75rem;
|
background: rgba(0, 0, 0, 0.3);
|
||||||
background: rgba(28, 28, 30, 0.6);
|
padding: 3px;
|
||||||
padding: 5px;
|
border-radius: 8px;
|
||||||
border-radius: 10px;
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
flex: 1;
|
padding: 5px 10px;
|
||||||
padding: 8px 10px;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-height: 40px;
|
min-height: 28px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn:hover {
|
.view-btn:hover {
|
||||||
@@ -1194,4 +1120,416 @@
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Card two-row layout
|
||||||
|
======================================== */
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row-primary {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row-secondary {
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.card-row-primary {
|
||||||
|
padding: 16px 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row-secondary {
|
||||||
|
padding: 0 24px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row .customer-name {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.card-row .customer-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 40vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.vehicle-badge {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-location {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.location-indicator {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-area {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-area.werkstatt {
|
||||||
|
background: rgba(155, 89, 182, 0.2);
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-area.lager {
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-part {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row-secondary .season-indicator {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tireset-card.selected {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Selected stack details panel
|
||||||
|
======================================== */
|
||||||
|
.stack-details-panel {
|
||||||
|
background: rgba(28, 28, 30, 0.95);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.stack-details-panel {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-details-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-details-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-details-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.stack-details-title h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-details-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.detail-columns {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-rows {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(110px, 38%) 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row dt {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row dd.mono {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row .muted {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Edit modal
|
||||||
|
======================================== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: #1c1c1e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group .form-control {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group .form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-error {
|
||||||
|
color: #f87171;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Tread depth measurement (detail list)
|
||||||
|
======================================== */
|
||||||
|
.tread-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-chip {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tread-chip.rating-ok { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.4); color: #86efac; }
|
||||||
|
.tread-chip.rating-warn { background: rgba(250, 204, 21, 0.15); border-color: rgba(250, 204, 21, 0.4); color: #fde047; }
|
||||||
|
.tread-chip.rating-crit { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
|
||||||
|
|
||||||
|
.report-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduced motion: inherited from app.css (section 17) */
|
/* Reduced motion: inherited from app.css (section 17) */
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@if (IsOpen)
|
||||||
|
{
|
||||||
|
<div class="qr-overlay" @onclick="Cancel">
|
||||||
|
<div class="qr-dialog" role="dialog" aria-modal="true" aria-labelledby="qr-title" @onclick:stopPropagation="true">
|
||||||
|
<div class="qr-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="qr-title">@Title</h3>
|
||||||
|
@if (!string.IsNullOrEmpty(Subtitle))
|
||||||
|
{
|
||||||
|
<p class="qr-subtitle">@Subtitle</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="qr-close" @onclick="Cancel" aria-label="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-body">
|
||||||
|
<div id="@_elementId" class="qr-viewport"></div>
|
||||||
|
|
||||||
|
@if (isStarting)
|
||||||
|
{
|
||||||
|
<div class="qr-status"><span class="spinner"></span> Kamera wird gestartet…</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(cameraError))
|
||||||
|
{
|
||||||
|
<div class="qr-status error">📷 @cameraError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="qr-status">QR-Code des Reports vor die Kamera halten</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="qr-manual">
|
||||||
|
<label for="qr-manual-input">Oder Link / Nummer einfügen</label>
|
||||||
|
<div class="qr-manual-row">
|
||||||
|
<input id="qr-manual-input" type="text" class="form-control" placeholder="https://files.cnlaunch.com/…?dataRecordSn=…"
|
||||||
|
@bind="manualText" @bind:event="oninput" @onkeydown="OnManualKeyDown" />
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SubmitManual" disabled="@string.IsNullOrWhiteSpace(manualText)">Übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsOpen { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
|
||||||
|
[Parameter] public string Title { get; set; } = "QR-Code scannen";
|
||||||
|
[Parameter] public string? Subtitle { get; set; }
|
||||||
|
[Parameter] public EventCallback<string> OnScanned { get; set; }
|
||||||
|
|
||||||
|
private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}";
|
||||||
|
private DotNetObjectReference<QrScannerModal>? _selfRef;
|
||||||
|
private bool _scannerActive;
|
||||||
|
private bool _wasOpen;
|
||||||
|
private bool isStarting;
|
||||||
|
private string? cameraError;
|
||||||
|
private string manualText = "";
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (IsOpen && !_wasOpen)
|
||||||
|
{
|
||||||
|
_wasOpen = true;
|
||||||
|
await StartScanner();
|
||||||
|
}
|
||||||
|
else if (!IsOpen && _wasOpen)
|
||||||
|
{
|
||||||
|
_wasOpen = false;
|
||||||
|
await StopScanner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartScanner()
|
||||||
|
{
|
||||||
|
cameraError = null;
|
||||||
|
isStarting = true;
|
||||||
|
manualText = "";
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_selfRef ??= DotNetObjectReference.Create(this);
|
||||||
|
await JS.InvokeVoidAsync("qrScanner.start", _elementId, _selfRef);
|
||||||
|
_scannerActive = true;
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
cameraError = ExtractMessage(ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
cameraError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isStarting = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractMessage(string raw)
|
||||||
|
{
|
||||||
|
if (raw.Contains("NotAllowedError", StringComparison.OrdinalIgnoreCase) || raw.Contains("Permission", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "Kamerazugriff wurde verweigert. Bitte Berechtigung im Browser erlauben.";
|
||||||
|
if (raw.Contains("NotFoundError", StringComparison.OrdinalIgnoreCase) || raw.Contains("Keine Kamera", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "Keine Kamera gefunden.";
|
||||||
|
var idx = raw.IndexOf('\n');
|
||||||
|
return idx > 0 ? raw[..idx] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopScanner()
|
||||||
|
{
|
||||||
|
if (!_scannerActive) return;
|
||||||
|
_scannerActive = false;
|
||||||
|
try { await JS.InvokeVoidAsync("qrScanner.stop"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnQrScanned(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
|
await Close();
|
||||||
|
await OnScanned.InvokeAsync(text.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitManual()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(manualText)) return;
|
||||||
|
var text = manualText.Trim();
|
||||||
|
await Close();
|
||||||
|
await OnScanned.InvokeAsync(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnManualKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter") await SubmitManual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Cancel() => await Close();
|
||||||
|
|
||||||
|
private async Task Close()
|
||||||
|
{
|
||||||
|
await StopScanner();
|
||||||
|
_wasOpen = false;
|
||||||
|
if (IsOpen)
|
||||||
|
{
|
||||||
|
IsOpen = false;
|
||||||
|
await IsOpenChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopScanner();
|
||||||
|
_selfRef?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.qr-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-dialog {
|
||||||
|
background: #1c1c1e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 92vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-subtitle {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-body {
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-viewport {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .qr-viewport video {
|
||||||
|
width: 100% !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .qr-viewport img[alt="Info icon"],
|
||||||
|
::deep .qr-viewport img[alt="Camera based scan"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-status.error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-status .spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: #dc2626;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: qr-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes qr-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-manual {
|
||||||
|
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-manual label {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-manual-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-manual-row .form-control {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
@using Lagerbestand.Models
|
||||||
|
@using Lagerbestand.Services
|
||||||
|
@inject IStorageLocationService StorageLocationService
|
||||||
|
|
||||||
|
@if (IsOpen)
|
||||||
|
{
|
||||||
|
<div class="picker-overlay" @onclick="Cancel">
|
||||||
|
<div class="picker-dialog" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="picker-title">
|
||||||
|
<header class="picker-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="picker-title">Lagerplatz wählen</h2>
|
||||||
|
@if (!string.IsNullOrEmpty(Subtitle))
|
||||||
|
{
|
||||||
|
<p class="picker-subtitle">@Subtitle</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="picker-close" @onclick="Cancel" aria-label="Schließen">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="picker-body">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="picker-loading" role="status">Lade Lagerplätze...</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="rack-view">
|
||||||
|
@foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
|
||||||
|
{
|
||||||
|
<div class="building-section" @key="area">
|
||||||
|
<h3 class="building-title">@area.GetBuildingLabel()</h3>
|
||||||
|
@for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
|
||||||
|
{
|
||||||
|
var currentRow = row;
|
||||||
|
<div class="rack-row" @key="@($"{area}-{currentRow}")">
|
||||||
|
<h4 class="row-title">Reihe @currentRow</h4>
|
||||||
|
<div class="rack-sides">
|
||||||
|
@foreach (var side in new[] { "Links", "Rechts" })
|
||||||
|
{
|
||||||
|
<div class="rack-side" @key="side">
|
||||||
|
<span class="side-label">@side</span>
|
||||||
|
@foreach (var pos in ShelfPositionExtensions.All.Where(p => p.IsLeft() == (side == "Links")).OrderByDescending(p => p.IsTop()))
|
||||||
|
{
|
||||||
|
<div class="rack-level" @key="pos">
|
||||||
|
<span class="level-label">@(pos.IsTop() ? "Oben" : "Unten")</span>
|
||||||
|
<div class="rack-cells">
|
||||||
|
@for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
|
||||||
|
{
|
||||||
|
var loc = GetLocation(area, currentRow, pos, slot);
|
||||||
|
var isHighlighted = loc != null && HighlightLocationId == loc.Id;
|
||||||
|
var isSelectable = loc != null && (isHighlighted || (loc.IsAvailable && !loc.IsOccupied));
|
||||||
|
var cellClass = loc == null ? "missing" : isHighlighted ? "freeing" : !loc.IsAvailable ? "blocked" : loc.IsOccupied ? "occupied" : "free";
|
||||||
|
<button type="button"
|
||||||
|
class="rack-cell @cellClass @(pendingLocation?.Id == loc?.Id ? "selected" : "")"
|
||||||
|
@key="slot"
|
||||||
|
disabled="@(!isSelectable)"
|
||||||
|
title="@GetTooltip(loc, area, currentRow, pos, slot, isHighlighted)"
|
||||||
|
@onclick="() => Select(loc)">
|
||||||
|
@slot
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rack-legend" aria-hidden="true">
|
||||||
|
<span class="legend-item"><span class="legend-swatch free"></span> Frei</span>
|
||||||
|
@if (HighlightLocationId.HasValue)
|
||||||
|
{
|
||||||
|
<span class="legend-item"><span class="legend-swatch freeing"></span> Wird frei</span>
|
||||||
|
}
|
||||||
|
<span class="legend-item"><span class="legend-swatch occupied"></span> Belegt</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch blocked"></span> Gesperrt</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="picker-footer">
|
||||||
|
<div class="picker-selection">
|
||||||
|
@if (pendingLocation != null)
|
||||||
|
{
|
||||||
|
<span class="picker-selection-label">Ausgewählt:</span>
|
||||||
|
<LocationDisplay Code="@pendingLocation.Code" DisplayMode="LocationDisplayMode.Full" Size="LocationDisplaySize.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="picker-selection-hint">Bitte einen freien Platz anklicken</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button type="button" class="btn btn-outline" @onclick="Cancel">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="Confirm" disabled="@(pendingLocation == null)">Übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsOpen { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Currently selected location (pre-selected when opening).</summary>
|
||||||
|
[Parameter] public Guid? SelectedLocationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Location that is about to be freed (e.g. during a swap) – shown as selectable.</summary>
|
||||||
|
[Parameter] public Guid? HighlightLocationId { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string? Subtitle { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public EventCallback<StorageLocation> OnSelected { get; set; }
|
||||||
|
|
||||||
|
private bool isLoading;
|
||||||
|
private bool wasOpen;
|
||||||
|
private List<StorageLocation> allLocations = new();
|
||||||
|
private Dictionary<(AreaType, int, ShelfPosition, int), StorageLocation> lookup = new();
|
||||||
|
private StorageLocation? pendingLocation;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (IsOpen && !wasOpen)
|
||||||
|
{
|
||||||
|
wasOpen = true;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else if (!IsOpen)
|
||||||
|
{
|
||||||
|
wasOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
allLocations = await StorageLocationService.GetAllAsync();
|
||||||
|
lookup = allLocations.ToDictionary(l => (l.Area, l.Row, l.Position, l.SlotNumber));
|
||||||
|
pendingLocation = SelectedLocationId.HasValue
|
||||||
|
? allLocations.FirstOrDefault(l => l.Id == SelectedLocationId.Value)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
|
||||||
|
=> lookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
|
||||||
|
|
||||||
|
private void Select(StorageLocation? loc)
|
||||||
|
{
|
||||||
|
if (loc == null) return;
|
||||||
|
pendingLocation = loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Confirm()
|
||||||
|
{
|
||||||
|
if (pendingLocation == null) return;
|
||||||
|
await OnSelected.InvokeAsync(pendingLocation);
|
||||||
|
await Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Cancel() => Close();
|
||||||
|
|
||||||
|
private async Task Close()
|
||||||
|
{
|
||||||
|
IsOpen = false;
|
||||||
|
await IsOpenChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot, bool isHighlighted)
|
||||||
|
{
|
||||||
|
var baseText = $"{area.GetDisplayName()}, Reihe {row}, {pos.GetDisplayName()}, Platz {slot}";
|
||||||
|
if (loc == null) return $"{baseText} – nicht angelegt";
|
||||||
|
if (isHighlighted) return $"{baseText} – wird frei";
|
||||||
|
if (!loc.IsAvailable) return $"{baseText} – gesperrt";
|
||||||
|
if (loc.IsOccupied) return $"{baseText} – belegt";
|
||||||
|
return $"{baseText} – frei";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/* ========================================
|
||||||
|
Overlay & Dialog
|
||||||
|
======================================== */
|
||||||
|
.picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-dialog {
|
||||||
|
background: #1c1c1e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
max-height: 92vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-subtitle {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-body {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-selection-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-selection-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Rack Grid (mirrors TireStorage rack view)
|
||||||
|
======================================== */
|
||||||
|
.rack-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.rack-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-section {
|
||||||
|
background: rgba(28, 28, 30, 0.8);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-row {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin: 0 0 0.3rem 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-sides {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.rack-sides {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-side {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-level {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-label {
|
||||||
|
width: 2.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cells {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(9, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell:not(:disabled):hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell.free {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border-color: rgba(34, 197, 94, 0.4);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell.freeing {
|
||||||
|
background: rgba(96, 165, 250, 0.18);
|
||||||
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell.occupied {
|
||||||
|
background: rgba(220, 38, 38, 0.2);
|
||||||
|
border-color: rgba(220, 38, 38, 0.45);
|
||||||
|
color: #f87171;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell.blocked {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
border-color: rgba(148, 163, 184, 0.3);
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell.missing {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-cell.selected {
|
||||||
|
outline: 2px solid #22c55e;
|
||||||
|
outline-offset: 1px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Legend
|
||||||
|
======================================== */
|
||||||
|
.rack-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.free {
|
||||||
|
background: rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.freeing {
|
||||||
|
background: rgba(96, 165, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.occupied {
|
||||||
|
background: rgba(220, 38, 38, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.blocked {
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<StorageLocation> StorageLocations => Set<StorageLocation>();
|
public DbSet<StorageLocation> StorageLocations => Set<StorageLocation>();
|
||||||
public DbSet<TireStack> TireStacks => Set<TireStack>();
|
public DbSet<TireStack> TireStacks => Set<TireStack>();
|
||||||
public DbSet<TireItem> TireItems => Set<TireItem>();
|
public DbSet<TireItem> TireItems => Set<TireItem>();
|
||||||
|
public DbSet<TireInspection> TireInspections => Set<TireInspection>();
|
||||||
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
|
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
|
||||||
|
|
||||||
// Kanban entities
|
// Kanban entities
|
||||||
@@ -84,7 +85,7 @@ public class AppDbContext : DbContext
|
|||||||
{
|
{
|
||||||
entity.ToTable("Customers");
|
entity.ToTable("Customers");
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
entity.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
|
entity.Property(e => e.FirstName).HasMaxLength(100);
|
||||||
entity.Property(e => e.LastName).IsRequired().HasMaxLength(100);
|
entity.Property(e => e.LastName).IsRequired().HasMaxLength(100);
|
||||||
entity.Property(e => e.Company).HasMaxLength(200);
|
entity.Property(e => e.Company).HasMaxLength(200);
|
||||||
entity.Property(e => e.Phone).HasMaxLength(20);
|
entity.Property(e => e.Phone).HasMaxLength(20);
|
||||||
@@ -230,6 +231,31 @@ public class AppDbContext : DbContext
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TireInspection configuration
|
||||||
|
modelBuilder.Entity<TireInspection>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("TireInspections");
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.Source).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.RecordSn).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.ReportUrl).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.LicensePlate).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Recommendation).HasMaxLength(1000);
|
||||||
|
entity.Property(e => e.RawJson).HasColumnType("LONGTEXT");
|
||||||
|
entity.Property(e => e.RatingFL).HasConversion<int>();
|
||||||
|
entity.Property(e => e.RatingFR).HasConversion<int>();
|
||||||
|
entity.Property(e => e.RatingRL).HasConversion<int>();
|
||||||
|
entity.Property(e => e.RatingRR).HasConversion<int>();
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.TireStackId);
|
||||||
|
entity.HasIndex(e => e.RecordSn);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.TireStack)
|
||||||
|
.WithMany(t => t.Inspections)
|
||||||
|
.HasForeignKey(e => e.TireStackId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
// SiteSettings configuration
|
// SiteSettings configuration
|
||||||
modelBuilder.Entity<SiteSettings>(entity =>
|
modelBuilder.Entity<SiteSettings>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.1.0</Version>
|
||||||
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Lagerbestand.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Condition rating of a single tire as reported by the measurement device.
|
||||||
|
/// </summary>
|
||||||
|
public enum TireMeasureRating
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Ok = 1,
|
||||||
|
Warning = 2,
|
||||||
|
Critical = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tread depth / condition measurement for a tire stack, typically imported
|
||||||
|
/// from a Launch TTS01 report (QR code) during checkin.
|
||||||
|
/// </summary>
|
||||||
|
public class TireInspection
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public Guid TireStackId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(TireStackId))]
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
|
public TireStack? TireStack { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Source { get; set; } = "Launch TTS01";
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? RecordSn { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? ReportUrl { get; set; }
|
||||||
|
|
||||||
|
public DateTime? MeasuredAt { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string? LicensePlate { get; set; }
|
||||||
|
|
||||||
|
public int? Mileage { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(5,2)")]
|
||||||
|
public decimal? TreadDepthFL { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(5,2)")]
|
||||||
|
public decimal? TreadDepthFR { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(5,2)")]
|
||||||
|
public decimal? TreadDepthRL { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(5,2)")]
|
||||||
|
public decimal? TreadDepthRR { get; set; }
|
||||||
|
|
||||||
|
public TireMeasureRating RatingFL { get; set; }
|
||||||
|
public TireMeasureRating RatingFR { get; set; }
|
||||||
|
public TireMeasureRating RatingRL { get; set; }
|
||||||
|
public TireMeasureRating RatingRR { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(1000)]
|
||||||
|
public string? Recommendation { get; set; }
|
||||||
|
|
||||||
|
public string? RawJson { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Guid? CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public decimal? MinTreadDepth
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var values = new[] { TreadDepthFL, TreadDepthFR, TreadDepthRL, TreadDepthRR }.Where(v => v.HasValue).Select(v => v!.Value).ToList();
|
||||||
|
return values.Count == 0 ? null : values.Min();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public TireMeasureRating WorstRating
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var ratings = new[] { RatingFL, RatingFR, RatingRL, RatingRR };
|
||||||
|
return ratings.Max();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public bool HasTreadData => TreadDepthFL.HasValue || TreadDepthFR.HasValue || TreadDepthRL.HasValue || TreadDepthRR.HasValue;
|
||||||
|
|
||||||
|
/// <summary>Legal minimum tread depth in Germany (mm).</summary>
|
||||||
|
public const decimal DefaultCriticalThreshold = 1.6m;
|
||||||
|
|
||||||
|
/// <summary>Common workshop recommendation threshold for replacement (mm).</summary>
|
||||||
|
public const decimal DefaultWarningThreshold = 3.0m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a rating from a tread depth in mm. Values below <paramref name="critical"/>
|
||||||
|
/// are critical, values up to <paramref name="warning"/> are a warning.
|
||||||
|
/// </summary>
|
||||||
|
public static TireMeasureRating RateDepth(decimal? mm, decimal? critical = null, decimal? warning = null)
|
||||||
|
{
|
||||||
|
if (!mm.HasValue) return TireMeasureRating.Unknown;
|
||||||
|
var crit = critical ?? DefaultCriticalThreshold;
|
||||||
|
var warn = Math.Max(warning ?? DefaultWarningThreshold, crit);
|
||||||
|
if (mm.Value < crit) return TireMeasureRating.Critical;
|
||||||
|
if (mm.Value <= warn) return TireMeasureRating.Warning;
|
||||||
|
return TireMeasureRating.Ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,13 @@ public enum TireStackStatus
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tires are in inventory (shop stock, not customer-owned)
|
/// Tires are in inventory (shop stock, not customer-owned)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Inventory = 4
|
Inventory = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tires are currently mounted on the customer's vehicle (after a seasonal swap).
|
||||||
|
/// They can be re-stored later when swapping back.
|
||||||
|
/// </summary>
|
||||||
|
OnVehicle = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -96,6 +102,11 @@ public class TireStack
|
|||||||
[ForeignKey(nameof(CatalogItemId))]
|
[ForeignKey(nameof(CatalogItemId))]
|
||||||
public CatalogItem? CatalogItem { get; set; }
|
public CatalogItem? CatalogItem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tread depth / condition measurements (e.g. imported from QR report)
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<TireInspection> Inspections { get; set; } = new List<TireInspection>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current status of the tire stack
|
/// Current status of the tire stack
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -234,6 +245,7 @@ public class TireStack
|
|||||||
TireStackStatus.Disposed => "Entsorgt",
|
TireStackStatus.Disposed => "Entsorgt",
|
||||||
TireStackStatus.Reserved => "Reserviert",
|
TireStackStatus.Reserved => "Reserviert",
|
||||||
TireStackStatus.Inventory => "Lagerbestand",
|
TireStackStatus.Inventory => "Lagerbestand",
|
||||||
|
TireStackStatus.OnVehicle => "Montiert",
|
||||||
_ => "Unbekannt"
|
_ => "Unbekannt"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ builder.Services.AddScoped<ILocationCodeService, LocationCodeService>();
|
|||||||
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
|
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
|
||||||
builder.Services.AddScoped<ITireStackService, TireStackService>();
|
builder.Services.AddScoped<ITireStackService, TireStackService>();
|
||||||
|
|
||||||
|
// Launch TTS01 tire report (QR-Code) lookup
|
||||||
|
builder.Services.AddHttpClient("LaunchReport", client => client.Timeout = TimeSpan.FromSeconds(15));
|
||||||
|
builder.Services.AddScoped<ITireReportService, TireReportService>();
|
||||||
|
|
||||||
// Register Kanban service
|
// Register Kanban service
|
||||||
builder.Services.AddScoped<IKanbanService, KanbanService>();
|
builder.Services.AddScoped<IKanbanService, KanbanService>();
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,6 @@ public class DatabaseInitializer : IDatabaseInitializer
|
|||||||
|
|
||||||
await using var connection = new MySqlConnection(connectionString);
|
await using var connection = new MySqlConnection(connectionString);
|
||||||
|
|
||||||
// Set connection timeout
|
|
||||||
connection.ConnectionTimeout.ToString();
|
|
||||||
|
|
||||||
await connection.OpenAsync(cancellationToken);
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
// Core tables
|
// Core tables
|
||||||
@@ -141,6 +138,7 @@ public class DatabaseInitializer : IDatabaseInitializer
|
|||||||
await ExecuteSqlSafeAsync(connection, CreateStorageLocationsTableSql, "StorageLocations", cancellationToken);
|
await ExecuteSqlSafeAsync(connection, CreateStorageLocationsTableSql, "StorageLocations", cancellationToken);
|
||||||
await ExecuteSqlSafeAsync(connection, CreateTireStacksTableSql, "TireStacks", cancellationToken);
|
await ExecuteSqlSafeAsync(connection, CreateTireStacksTableSql, "TireStacks", cancellationToken);
|
||||||
await ExecuteSqlSafeAsync(connection, CreateTireItemsTableSql, "TireItems", cancellationToken);
|
await ExecuteSqlSafeAsync(connection, CreateTireItemsTableSql, "TireItems", cancellationToken);
|
||||||
|
await ExecuteSqlSafeAsync(connection, CreateTireInspectionsTableSql, "TireInspections", cancellationToken);
|
||||||
|
|
||||||
// Kanban tables
|
// Kanban tables
|
||||||
await ExecuteSqlSafeAsync(connection, CreateKanbanColumnsTableSql, "KanbanColumns", cancellationToken);
|
await ExecuteSqlSafeAsync(connection, CreateKanbanColumnsTableSql, "KanbanColumns", cancellationToken);
|
||||||
@@ -205,6 +203,27 @@ public class DatabaseInitializer : IDatabaseInitializer
|
|||||||
"ALTER TABLE `Customers` MODIFY COLUMN `FirstName` VARCHAR(100) NULL;",
|
"ALTER TABLE `Customers` MODIFY COLUMN `FirstName` VARCHAR(100) NULL;",
|
||||||
"MakeCustomerFirstNameNullable", cancellationToken);
|
"MakeCustomerFirstNameNullable", cancellationToken);
|
||||||
|
|
||||||
|
// Data repair: backfill NULLs in columns mapped as non-nullable strings
|
||||||
|
// (prevents InvalidCastException: DBNull -> String when materializing entities)
|
||||||
|
await ExecuteSqlSafeAsync(connection,
|
||||||
|
"UPDATE `Customers` SET `LastName` = '-' WHERE `LastName` IS NULL;",
|
||||||
|
"BackfillCustomerLastName", cancellationToken);
|
||||||
|
await ExecuteSqlSafeAsync(connection,
|
||||||
|
"UPDATE `Customers` SET `CustomerNumber` = CONCAT('AUTO-', SUBSTRING(REPLACE(`Id`, '-', ''), 1, 12)) WHERE `CustomerNumber` IS NULL;",
|
||||||
|
"BackfillCustomerNumber", cancellationToken);
|
||||||
|
await ExecuteSqlSafeAsync(connection,
|
||||||
|
"ALTER TABLE `Customers` MODIFY COLUMN `LastName` VARCHAR(100) NOT NULL, MODIFY COLUMN `CustomerNumber` VARCHAR(20) NOT NULL;",
|
||||||
|
"EnforceCustomerNotNullColumns", cancellationToken);
|
||||||
|
await ExecuteSqlSafeAsync(connection,
|
||||||
|
"UPDATE `Vehicles` SET `Make` = 'Unbekannt' WHERE `Make` IS NULL;",
|
||||||
|
"BackfillVehicleMake", cancellationToken);
|
||||||
|
await ExecuteSqlSafeAsync(connection,
|
||||||
|
"UPDATE `Vehicles` SET `TireSize` = '' WHERE `TireSize` IS NULL;",
|
||||||
|
"BackfillVehicleTireSize", cancellationToken);
|
||||||
|
await ExecuteSqlSafeAsync(connection,
|
||||||
|
"ALTER TABLE `Vehicles` MODIFY COLUMN `Make` VARCHAR(100) NOT NULL, MODIFY COLUMN `TireSize` VARCHAR(50) NOT NULL DEFAULT '';",
|
||||||
|
"EnforceVehicleNotNullColumns", cancellationToken);
|
||||||
|
|
||||||
// Migration: Add 2FA columns to Users table
|
// Migration: Add 2FA columns to Users table
|
||||||
await MigrateAdd2FaColumnsToUsersAsync(connection, cancellationToken);
|
await MigrateAdd2FaColumnsToUsersAsync(connection, cancellationToken);
|
||||||
|
|
||||||
@@ -1020,6 +1039,35 @@ public class DatabaseInitializer : IDatabaseInitializer
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
private const string CreateTireInspectionsTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS `TireInspections` (
|
||||||
|
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
|
||||||
|
`TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL,
|
||||||
|
`Source` VARCHAR(50) NOT NULL DEFAULT 'Launch TTS01',
|
||||||
|
`RecordSn` VARCHAR(100) NULL,
|
||||||
|
`ReportUrl` VARCHAR(500) NULL,
|
||||||
|
`MeasuredAt` DATETIME(6) NULL,
|
||||||
|
`LicensePlate` VARCHAR(20) NULL,
|
||||||
|
`Mileage` INT NULL,
|
||||||
|
`TreadDepthFL` DECIMAL(5,2) NULL,
|
||||||
|
`TreadDepthFR` DECIMAL(5,2) NULL,
|
||||||
|
`TreadDepthRL` DECIMAL(5,2) NULL,
|
||||||
|
`TreadDepthRR` DECIMAL(5,2) NULL,
|
||||||
|
`RatingFL` INT NOT NULL DEFAULT 0,
|
||||||
|
`RatingFR` INT NOT NULL DEFAULT 0,
|
||||||
|
`RatingRL` INT NOT NULL DEFAULT 0,
|
||||||
|
`RatingRR` INT NOT NULL DEFAULT 0,
|
||||||
|
`Recommendation` VARCHAR(1000) NULL,
|
||||||
|
`RawJson` LONGTEXT NULL,
|
||||||
|
`CreatedAt` DATETIME(6) NOT NULL,
|
||||||
|
`CreatedByUserId` CHAR(36) CHARACTER SET ascii NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
INDEX `IX_TireInspections_TireStackId` (`TireStackId`),
|
||||||
|
INDEX `IX_TireInspections_RecordSn` (`RecordSn`),
|
||||||
|
CONSTRAINT `FK_TireInspections_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
#region Kanban SQL Statements
|
#region Kanban SQL Statements
|
||||||
|
|
||||||
private const string CreateKanbanColumnsTableSql = """
|
private const string CreateKanbanColumnsTableSql = """
|
||||||
|
|||||||
@@ -290,24 +290,25 @@ public class StorageLocationService : IStorageLocationService
|
|||||||
var toLocation = await GetByCodeAsync(toCode)
|
var toLocation = await GetByCodeAsync(toCode)
|
||||||
?? throw new LocationNotFoundException(toCode);
|
?? throw new LocationNotFoundException(toCode);
|
||||||
|
|
||||||
if (!fromLocation.IsOccupied)
|
if (!fromLocation.IsOccupied || fromLocation.TireStackId == null)
|
||||||
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
|
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
|
||||||
|
|
||||||
if (toLocation.IsOccupied)
|
if (toLocation.IsOccupied)
|
||||||
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
|
throw toLocation.TireStackId.HasValue
|
||||||
|
? new LocationOccupiedException(toCode, toLocation.TireStackId.Value)
|
||||||
|
: new LocationOccupiedException(toCode);
|
||||||
|
|
||||||
if (!toLocation.IsAvailable)
|
if (!toLocation.IsAvailable)
|
||||||
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
|
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
|
||||||
|
|
||||||
var tireStackId = fromLocation.TireStackId!.Value;
|
var tireStackId = fromLocation.TireStackId.Value;
|
||||||
|
|
||||||
// Update the tire stack's storage location reference
|
// Update the tire stack's storage location reference
|
||||||
var tireStack = await _context.TireStacks.FindAsync(tireStackId);
|
var tireStack = await _context.TireStacks.FindAsync(tireStackId)
|
||||||
if (tireStack != null)
|
?? throw new InvalidOperationException($"Reifenstapel auf Lagerplatz {fromCode} wurde nicht gefunden.");
|
||||||
{
|
|
||||||
tireStack.StorageLocationId = toLocation.Id;
|
tireStack.StorageLocationId = toLocation.Id;
|
||||||
tireStack.UpdatedAt = DateTime.UtcNow;
|
tireStack.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
|
||||||
|
|
||||||
// Clear source and assign to destination
|
// Clear source and assign to destination
|
||||||
fromLocation.ClearLocation();
|
fromLocation.ClearLocation();
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Lagerbestand.Models;
|
||||||
|
|
||||||
|
namespace Lagerbestand.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed data of a tire measurement report (Launch TTS01 tread depth scanner).
|
||||||
|
/// </summary>
|
||||||
|
public class TireReportData
|
||||||
|
{
|
||||||
|
public string RecordSn { get; set; } = string.Empty;
|
||||||
|
public string ReportUrl { get; set; } = string.Empty;
|
||||||
|
public DateTime? MeasuredAt { get; set; }
|
||||||
|
public string? LicensePlate { get; set; }
|
||||||
|
public int? Mileage { get; set; }
|
||||||
|
public decimal? TreadDepthFL { get; set; }
|
||||||
|
public decimal? TreadDepthFR { get; set; }
|
||||||
|
public decimal? TreadDepthRL { get; set; }
|
||||||
|
public decimal? TreadDepthRR { get; set; }
|
||||||
|
public TireMeasureRating RatingFL { get; set; }
|
||||||
|
public TireMeasureRating RatingFR { get; set; }
|
||||||
|
public TireMeasureRating RatingRL { get; set; }
|
||||||
|
public TireMeasureRating RatingRR { get; set; }
|
||||||
|
public decimal? WarningThreshold { get; set; }
|
||||||
|
public decimal? CriticalThreshold { get; set; }
|
||||||
|
public decimal? BrakingDistanceDelta { get; set; }
|
||||||
|
public string? Recommendation { get; set; }
|
||||||
|
public string? DeviceSerial { get; set; }
|
||||||
|
public string? RawJson { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True if the report itself contained tread depth values.</summary>
|
||||||
|
public bool HasTreadData => TreadDepthFL.HasValue || TreadDepthFR.HasValue || TreadDepthRL.HasValue || TreadDepthRR.HasValue;
|
||||||
|
|
||||||
|
public TireInspection ToInspection(Guid tireStackId, Guid? userId) => new()
|
||||||
|
{
|
||||||
|
TireStackId = tireStackId,
|
||||||
|
Source = "Launch TTS01",
|
||||||
|
RecordSn = Truncate(RecordSn, 100),
|
||||||
|
ReportUrl = Truncate(ReportUrl, 500),
|
||||||
|
MeasuredAt = MeasuredAt,
|
||||||
|
LicensePlate = Truncate(LicensePlate?.Trim().ToUpperInvariant(), 20),
|
||||||
|
Mileage = Mileage is < 0 ? null : Mileage,
|
||||||
|
TreadDepthFL = TreadDepthFL,
|
||||||
|
TreadDepthFR = TreadDepthFR,
|
||||||
|
TreadDepthRL = TreadDepthRL,
|
||||||
|
TreadDepthRR = TreadDepthRR,
|
||||||
|
RatingFL = RatingFL,
|
||||||
|
RatingFR = RatingFR,
|
||||||
|
RatingRL = RatingRL,
|
||||||
|
RatingRR = RatingRR,
|
||||||
|
Recommendation = Truncate(Recommendation, 1000),
|
||||||
|
RawJson = RawJson,
|
||||||
|
CreatedByUserId = userId
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-evaluates the per-wheel ratings from the current tread depths using the
|
||||||
|
/// thresholds delivered by the device (falls back to legal defaults).
|
||||||
|
/// </summary>
|
||||||
|
public void RecomputeRatings()
|
||||||
|
{
|
||||||
|
RatingFL = TireInspection.RateDepth(TreadDepthFL, CriticalThreshold, WarningThreshold);
|
||||||
|
RatingFR = TireInspection.RateDepth(TreadDepthFR, CriticalThreshold, WarningThreshold);
|
||||||
|
RatingRL = TireInspection.RateDepth(TreadDepthRL, CriticalThreshold, WarningThreshold);
|
||||||
|
RatingRR = TireInspection.RateDepth(TreadDepthRR, CriticalThreshold, WarningThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Truncate(string? s, int max)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return s;
|
||||||
|
return s.Length <= max ? s : s[..max];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ITireReportService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to extract the report record number from a scanned QR text
|
||||||
|
/// (full share URL or bare record number).
|
||||||
|
/// </summary>
|
||||||
|
bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads and parses the report. Returns a data object with at least
|
||||||
|
/// RecordSn/ReportUrl; HasTreadData indicates whether values could be read.
|
||||||
|
/// Throws <see cref="TireReportException"/> with a user-friendly message on failure.
|
||||||
|
/// </summary>
|
||||||
|
Task<TireReportData> FetchAsync(string recordSn, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TireReportException : Exception
|
||||||
|
{
|
||||||
|
public TireReportException(string message, Exception? inner = null) : base(message, inner) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TireReportService : ITireReportService
|
||||||
|
{
|
||||||
|
private const string DefaultShareUrl = "https://files.cnlaunch.com/assets/TTS01DetectReportShare.html?dataRecordSn=";
|
||||||
|
private const string DefaultApiUrl = "https://files.cnlaunch.com/api/ttm/getTtmReportData";
|
||||||
|
|
||||||
|
private static readonly Regex RecordSnRegex = new(@"dataRecordSn=([A-Za-z0-9_-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex BareSnRegex = new(@"^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<TireReportService> _logger;
|
||||||
|
private readonly string _apiUrl;
|
||||||
|
private readonly string _shareUrl;
|
||||||
|
|
||||||
|
public TireReportService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<TireReportService> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_apiUrl = configuration["LaunchReport:ApiUrl"] ?? DefaultApiUrl;
|
||||||
|
_shareUrl = configuration["LaunchReport:ShareUrl"] ?? DefaultShareUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl)
|
||||||
|
{
|
||||||
|
recordSn = string.Empty;
|
||||||
|
reportUrl = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(qrText)) return false;
|
||||||
|
|
||||||
|
var text = qrText.Trim();
|
||||||
|
var m = RecordSnRegex.Match(text);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
recordSn = m.Groups[1].Value;
|
||||||
|
reportUrl = text.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? text : _shareUrl + recordSn;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BareSnRegex.IsMatch(text))
|
||||||
|
{
|
||||||
|
recordSn = text;
|
||||||
|
reportUrl = _shareUrl + recordSn;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TireReportData> FetchAsync(string recordSn, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new TireReportData
|
||||||
|
{
|
||||||
|
RecordSn = recordSn,
|
||||||
|
ReportUrl = _shareUrl + recordSn
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient("LaunchReport");
|
||||||
|
string json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsJsonAsync(_apiUrl, new { dataRecordSn = recordSn }, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Launch report {Sn}: HTTP {Status}", recordSn, (int)response.StatusCode);
|
||||||
|
throw new TireReportException($"Report-Server antwortete mit HTTP {(int)response.StatusCode}.");
|
||||||
|
}
|
||||||
|
json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TireReportException) { throw; }
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TireReportException("Zeitüberschreitung beim Laden des Reports.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Launch report {Sn}: Netzwerkfehler", recordSn);
|
||||||
|
throw new TireReportException("Report-Server nicht erreichbar. Bitte Internetverbindung prüfen.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.RawJson = json;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("code", out var codeEl) && codeEl.ValueKind == JsonValueKind.Number && codeEl.GetInt32() != 200)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Launch report {Sn} returned code {Code}", recordSn, codeEl.GetInt32());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement report;
|
||||||
|
if (root.TryGetProperty("data", out var dataEl) && dataEl.ValueKind == JsonValueKind.Object
|
||||||
|
&& dataEl.TryGetProperty("reportInfoData", out var rid))
|
||||||
|
{
|
||||||
|
report = rid;
|
||||||
|
}
|
||||||
|
else if (root.TryGetProperty("detectData", out var dd))
|
||||||
|
{
|
||||||
|
report = dd;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Parse(report, result);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Launch report {Sn}: JSON konnte nicht gelesen werden", recordSn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Parse(JsonElement report, TireReportData result)
|
||||||
|
{
|
||||||
|
if (report.TryGetProperty("t", out var t))
|
||||||
|
{
|
||||||
|
long seconds = t.ValueKind == JsonValueKind.Number ? t.GetInt64()
|
||||||
|
: long.TryParse(t.GetString(), out var ls) ? ls : 0;
|
||||||
|
if (seconds > 100000)
|
||||||
|
result.MeasuredAt = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.TryGetProperty("sn", out var sn) && sn.ValueKind == JsonValueKind.String)
|
||||||
|
result.DeviceSerial = sn.GetString();
|
||||||
|
|
||||||
|
if (report.TryGetProperty("i", out var info) && info.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (info.TryGetProperty("cn", out var cn) && cn.ValueKind == JsonValueKind.String)
|
||||||
|
result.LicensePlate = NullIfEmpty(cn.GetString());
|
||||||
|
if (info.TryGetProperty("dt", out var dt))
|
||||||
|
{
|
||||||
|
if (dt.ValueKind == JsonValueKind.Number && dt.TryGetInt32(out var km)) result.Mileage = km;
|
||||||
|
else if (dt.ValueKind == JsonValueKind.String && int.TryParse(dt.GetString(), out var km2)) result.Mileage = km2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report.TryGetProperty("dr", out var dr) || dr.ValueKind != JsonValueKind.Object)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Thresholds "critical-warning", e.g. "1.6-2.5"
|
||||||
|
if (dr.TryGetProperty("ts", out var ts) && ts.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var parts = ts.GetString()!.Split('-');
|
||||||
|
if (parts.Length == 2)
|
||||||
|
{
|
||||||
|
result.CriticalThreshold = ParseDecimal(parts[0]);
|
||||||
|
result.WarningThreshold = ParseDecimal(parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dr.TryGetProperty("d", out var d))
|
||||||
|
result.BrakingDistanceDelta = d.ValueKind == JsonValueKind.Number ? d.GetDecimal() : ParseDecimal(d.GetString());
|
||||||
|
|
||||||
|
// Minimum values per wheel: l1 = front left, r1 = front right, ll1 = rear left, rr1 = rear right
|
||||||
|
if (dr.TryGetProperty("rl", out var rl) && rl.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
result.TreadDepthFL = GetDecimal(rl, "l1");
|
||||||
|
result.TreadDepthFR = GetDecimal(rl, "r1");
|
||||||
|
result.TreadDepthRL = GetDecimal(rl, "ll1");
|
||||||
|
result.TreadDepthRR = GetDecimal(rl, "rr1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: compute minimum from raw readings (z = axle, p = side, gd = readings)
|
||||||
|
if (!result.HasTreadData && dr.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var tire in r.EnumerateArray())
|
||||||
|
{
|
||||||
|
var z = tire.TryGetProperty("z", out var ze) ? ze.GetInt32() : 0;
|
||||||
|
var p = tire.TryGetProperty("p", out var pe) ? pe.GetInt32() : 0;
|
||||||
|
decimal? min = null;
|
||||||
|
if (tire.TryGetProperty("gd", out var gd) && gd.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var s in gd.EnumerateArray())
|
||||||
|
{
|
||||||
|
foreach (var v in (s.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var dv = ParseDecimal(v);
|
||||||
|
if (dv.HasValue && (!min.HasValue || dv < min)) min = dv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (z, p)
|
||||||
|
{
|
||||||
|
case (1, 1): result.TreadDepthFL = min; break;
|
||||||
|
case (1, 2): result.TreadDepthFR = min; break;
|
||||||
|
case (2, 1): result.TreadDepthRL = min; break;
|
||||||
|
case (2, 2): result.TreadDepthRR = min; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.RecomputeRatings();
|
||||||
|
|
||||||
|
result.Recommendation = BuildRecommendation(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildRecommendation(TireReportData d)
|
||||||
|
{
|
||||||
|
var worst = new[] { d.RatingFL, d.RatingFR, d.RatingRL, d.RatingRR }.Max();
|
||||||
|
var parts = new List<string>();
|
||||||
|
|
||||||
|
switch (worst)
|
||||||
|
{
|
||||||
|
case TireMeasureRating.Critical:
|
||||||
|
parts.Add("Profiltiefe unter Mindestwert – Reifen umgehend ersetzen.");
|
||||||
|
break;
|
||||||
|
case TireMeasureRating.Warning:
|
||||||
|
parts.Add("Profiltiefe im Grenzbereich – Reifenwechsel empfohlen.");
|
||||||
|
break;
|
||||||
|
case TireMeasureRating.Ok:
|
||||||
|
parts.Add("Profiltiefe in Ordnung.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var depths = new[] { d.TreadDepthFL, d.TreadDepthFR, d.TreadDepthRL, d.TreadDepthRR }.Where(x => x.HasValue).Select(x => x!.Value).ToList();
|
||||||
|
if (depths.Count >= 2 && depths.Max() - depths.Min() >= 2m)
|
||||||
|
parts.Add("Ungleichmäßige Abnutzung (Differenz ≥ 2 mm) – Achsvermessung/Rotation prüfen.");
|
||||||
|
|
||||||
|
if (d.BrakingDistanceDelta is > 0)
|
||||||
|
parts.Add($"Bremsweg ca. +{d.BrakingDistanceDelta.Value.ToString("0.#", CultureInfo.GetCultureInfo("de-DE"))} m gegenüber Neureifen.");
|
||||||
|
|
||||||
|
return parts.Count == 0 ? null : string.Join(" ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? GetDecimal(JsonElement obj, string name)
|
||||||
|
{
|
||||||
|
if (!obj.TryGetProperty(name, out var el)) return null;
|
||||||
|
return el.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number => el.GetDecimal(),
|
||||||
|
JsonValueKind.String => ParseDecimal(el.GetString()),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ParseDecimal(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
return decimal.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
}
|
||||||
@@ -23,10 +23,25 @@ public interface ITireStackService
|
|||||||
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
|
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
|
||||||
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
|
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
|
||||||
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
|
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
|
||||||
|
Task<SwapResult> SwapAsync(Guid outgoingTireStackId, TireStack incomingTireStack, Guid? targetLocationId = null, Guid? userId = null);
|
||||||
Task<bool> DeleteAsync(Guid id);
|
Task<bool> DeleteAsync(Guid id);
|
||||||
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
|
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
|
||||||
Task<StorageStatistics> GetStatisticsAsync();
|
Task<StorageStatistics> GetStatisticsAsync();
|
||||||
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
|
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
|
||||||
|
Task<TireInspection> AddInspectionAsync(TireInspection inspection);
|
||||||
|
Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId);
|
||||||
|
Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a seasonal tire swap (one stack out, another stack in)
|
||||||
|
/// </summary>
|
||||||
|
public class SwapResult
|
||||||
|
{
|
||||||
|
public required TireStack Outgoing { get; init; }
|
||||||
|
public required TireStack Incoming { get; init; }
|
||||||
|
public string? FreedLocationCode { get; init; }
|
||||||
|
public string? NewLocationCode { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -303,13 +318,13 @@ public class TireStackService : ITireStackService
|
|||||||
var tireStack = await _context.TireStacks
|
var tireStack = await _context.TireStacks
|
||||||
.Include(ts => ts.Customer)
|
.Include(ts => ts.Customer)
|
||||||
.Include(ts => ts.StorageLocation)
|
.Include(ts => ts.StorageLocation)
|
||||||
.FirstOrDefaultAsync(ts => ts.Id == tireStackId);
|
.FirstOrDefaultAsync(ts => ts.Id == tireStackId && ts.IsActive);
|
||||||
|
|
||||||
if (tireStack == null)
|
if (tireStack == null)
|
||||||
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
|
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
|
||||||
|
|
||||||
if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved)
|
if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved)
|
||||||
throw new InvalidOperationException("Reifenstapel ist nicht eingelagert.");
|
throw new InvalidOperationException($"Reifenstapel {tireStack.TicketNumber} ist nicht eingelagert.");
|
||||||
|
|
||||||
// Clear the storage location (both sides)
|
// Clear the storage location (both sides)
|
||||||
if (tireStack.StorageLocation != null)
|
if (tireStack.StorageLocation != null)
|
||||||
@@ -402,6 +417,111 @@ public class TireStackService : ITireStackService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SwapResult> SwapAsync(Guid outgoingTireStackId, TireStack incomingTireStack, Guid? targetLocationId = null, Guid? userId = null)
|
||||||
|
{
|
||||||
|
var strategy = _context.Database.CreateExecutionStrategy();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var outgoing = await _context.TireStacks
|
||||||
|
.Include(ts => ts.Customer)
|
||||||
|
.Include(ts => ts.Vehicle)
|
||||||
|
.Include(ts => ts.StorageLocation)
|
||||||
|
.FirstOrDefaultAsync(ts => ts.Id == outgoingTireStackId && ts.IsActive);
|
||||||
|
|
||||||
|
if (outgoing == null)
|
||||||
|
throw new InvalidOperationException("Herauszugebender Reifenstapel nicht gefunden.");
|
||||||
|
if (outgoing.Status != TireStackStatus.Stored && outgoing.Status != TireStackStatus.Reserved)
|
||||||
|
throw new InvalidOperationException("Herauszugebender Reifenstapel ist nicht eingelagert.");
|
||||||
|
if (outgoing.CustomerId == null)
|
||||||
|
throw new InvalidOperationException("Reifenwechsel ist nur für Kundenreifen möglich.");
|
||||||
|
|
||||||
|
var freedLocationId = outgoing.StorageLocationId;
|
||||||
|
var freedLocationCode = outgoing.StorageLocation?.Code;
|
||||||
|
|
||||||
|
// 1. Release outgoing stack: mark as mounted on vehicle, free the location
|
||||||
|
if (outgoing.StorageLocation != null)
|
||||||
|
outgoing.StorageLocation.ClearLocation();
|
||||||
|
|
||||||
|
outgoing.Status = TireStackStatus.OnVehicle;
|
||||||
|
outgoing.PickedUpAt = DateTime.UtcNow;
|
||||||
|
outgoing.PickedUpByUserId = userId;
|
||||||
|
outgoing.StorageLocationId = null;
|
||||||
|
outgoing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 2. Resolve incoming stack: existing (from history) or new
|
||||||
|
TireStack incoming;
|
||||||
|
var existing = await _context.TireStacks
|
||||||
|
.FirstOrDefaultAsync(ts => ts.Id == incomingTireStack.Id && ts.IsActive);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
if (existing.Status != TireStackStatus.OnVehicle && existing.Status != TireStackStatus.PickedUp)
|
||||||
|
throw new InvalidOperationException($"Reifenstapel {existing.TicketNumber} kann nicht wieder eingelagert werden (Status: {existing.StatusText}).");
|
||||||
|
if (existing.CustomerId != outgoing.CustomerId)
|
||||||
|
throw new InvalidOperationException("Der einzulagernde Reifenstapel gehört einem anderen Kunden.");
|
||||||
|
|
||||||
|
existing.PickedUpAt = null;
|
||||||
|
existing.PickedUpByUserId = null;
|
||||||
|
existing.ExpectedPickupDate = incomingTireStack.ExpectedPickupDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(incomingTireStack.Notes))
|
||||||
|
existing.Notes = incomingTireStack.Notes;
|
||||||
|
existing.Condition = incomingTireStack.Condition;
|
||||||
|
if (incomingTireStack.VehicleId != null)
|
||||||
|
existing.VehicleId = incomingTireStack.VehicleId;
|
||||||
|
incoming = existing;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
incomingTireStack.CustomerId = outgoing.CustomerId;
|
||||||
|
incomingTireStack.VehicleId ??= outgoing.VehicleId;
|
||||||
|
incomingTireStack.CatalogItemId = null;
|
||||||
|
incomingTireStack.ItemCount = TireStack.TiresPerStack;
|
||||||
|
incomingTireStack.PickedUpAt = null;
|
||||||
|
incomingTireStack.PickedUpByUserId = null;
|
||||||
|
incoming = await CreateAsync(incomingTireStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Store incoming stack on target (default: the location just freed)
|
||||||
|
var locationId = targetLocationId ?? freedLocationId
|
||||||
|
?? throw new InvalidOperationException("Kein Lagerplatz für die Einlagerung angegeben.");
|
||||||
|
|
||||||
|
incoming = await StoreAsync(incoming, locationId, userId);
|
||||||
|
if (incoming.StorageLocation == null)
|
||||||
|
incoming.StorageLocation = await _context.StorageLocations.FindAsync(locationId);
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Tire swap: {Outgoing} -> vehicle, {Incoming} -> location {Location}",
|
||||||
|
outgoing.TicketNumber, incoming.TicketNumber, incoming.StorageLocation?.Code);
|
||||||
|
|
||||||
|
return new SwapResult
|
||||||
|
{
|
||||||
|
Outgoing = outgoing,
|
||||||
|
Incoming = incoming,
|
||||||
|
FreedLocationCode = freedLocationCode,
|
||||||
|
NewLocationCode = incoming.StorageLocation?.Code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Der Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteAsync(Guid id)
|
public async Task<bool> DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
var tireStack = await _context.TireStacks.FindAsync(id);
|
var tireStack = await _context.TireStacks.FindAsync(id);
|
||||||
@@ -501,4 +621,34 @@ public class TireStackService : ITireStackService
|
|||||||
.OrderBy(ts => ts.ExpectedPickupDate)
|
.OrderBy(ts => ts.ExpectedPickupDate)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TireInspection> AddInspectionAsync(TireInspection inspection)
|
||||||
|
{
|
||||||
|
var exists = await _context.TireStacks.AnyAsync(ts => ts.Id == inspection.TireStackId);
|
||||||
|
if (!exists)
|
||||||
|
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
|
||||||
|
|
||||||
|
if (inspection.Id == Guid.Empty) inspection.Id = Guid.NewGuid();
|
||||||
|
inspection.CreatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
_context.TireInspections.Add(inspection);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return inspection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId)
|
||||||
|
{
|
||||||
|
return await _context.TireInspections
|
||||||
|
.Where(i => i.TireStackId == tireStackId)
|
||||||
|
.OrderByDescending(i => i.MeasuredAt ?? i.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId)
|
||||||
|
{
|
||||||
|
return await _context.TireInspections
|
||||||
|
.Where(i => i.TireStackId == tireStackId)
|
||||||
|
.OrderByDescending(i => i.MeasuredAt ?? i.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,5 +13,9 @@
|
|||||||
"Username": "spaethf",
|
"Username": "spaethf",
|
||||||
"Password": "DDsm4$n3",
|
"Password": "DDsm4$n3",
|
||||||
"DisplayName": "Administrator"
|
"DisplayName": "Administrator"
|
||||||
|
},
|
||||||
|
"LaunchReport": {
|
||||||
|
"ApiUrl": "https://files.cnlaunch.com/api/ttm/getTtmReportData",
|
||||||
|
"ShareUrl": "https://files.cnlaunch.com/assets/TTS01DetectReportShare.html?dataRecordSn="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// QR code scanner interop (html5-qrcode) for Blazor.
|
||||||
|
// Usage from .NET: qrScanner.start(elementId, dotNetRef) / qrScanner.stop()
|
||||||
|
window.qrScanner = (function () {
|
||||||
|
let scanner = null;
|
||||||
|
let lastText = null;
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
async function ensureLib() {
|
||||||
|
if (window.Html5Qrcode) return;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'lib/html5-qrcode/html5-qrcode.min.js';
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error('QR-Bibliothek konnte nicht geladen werden.'));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(elementId, dotNetRef) {
|
||||||
|
await stop();
|
||||||
|
await ensureLib();
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
throw new Error('Kamera wird von diesem Browser nicht unterstützt (HTTPS erforderlich).');
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner = new Html5Qrcode(elementId, {
|
||||||
|
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
|
||||||
|
verbose: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
fps: 10,
|
||||||
|
qrbox: (w, h) => {
|
||||||
|
const size = Math.max(180, Math.min(w, h) * 0.7);
|
||||||
|
return { width: size, height: size };
|
||||||
|
},
|
||||||
|
aspectRatio: 1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSuccess = (text) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (text === lastText && now - lastTime < 2500) return;
|
||||||
|
lastText = text;
|
||||||
|
lastTime = now;
|
||||||
|
dotNetRef.invokeMethodAsync('OnQrScanned', text);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scanner.start({ facingMode: 'environment' }, config, onSuccess, () => { });
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: any camera
|
||||||
|
const cams = await Html5Qrcode.getCameras();
|
||||||
|
if (!cams || cams.length === 0) throw new Error('Keine Kamera gefunden.');
|
||||||
|
await scanner.start(cams[0].id, config, onSuccess, () => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!scanner) return;
|
||||||
|
try {
|
||||||
|
if (scanner.isScanning) await scanner.stop();
|
||||||
|
scanner.clear();
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
scanner = null;
|
||||||
|
lastText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short audio feedback ("success" | "error") via Web Audio, no asset required.
|
||||||
|
function playSound(type) {
|
||||||
|
try {
|
||||||
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!Ctx) return;
|
||||||
|
const ctx = new Ctx();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = type === 'error' ? 220 : 880;
|
||||||
|
gain.gain.value = 0.08;
|
||||||
|
osc.connect(gain).connect(ctx.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + (type === 'error' ? 0.25 : 0.12));
|
||||||
|
osc.onended = () => ctx.close().catch(() => { });
|
||||||
|
} catch (e) { /* audio is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, playSound };
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
var __Html5QrcodeLibrary__;(()=>{var t={449:function(t,e,r){!function(t){"use strict";function e(t){return null==t}var n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])};var i,o=function(t){function e(e){var r,n,i,o=this.constructor,s=t.call(this,e)||this;return Object.defineProperty(s,"name",{value:o.name,enumerable:!1}),r=s,n=o.prototype,(i=Object.setPrototypeOf)?i(r,n):r.__proto__=n,function(t,e){void 0===e&&(e=t.constructor);var r=Error.captureStackTrace;r&&r(t,e)}(s),s}return function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(e,t),e}(Error);class s extends o{constructor(t=undefined){super(t),this.message=t}getKind(){return this.constructor.kind}}s.kind="Exception";class a extends s{}a.kind="ArgumentException";class c extends s{}c.kind="IllegalArgumentException";class l{constructor(t){if(this.binarizer=t,null===t)throw new c("Binarizer must be non-null.")}getWidth(){return this.binarizer.getWidth()}getHeight(){return this.binarizer.getHeight()}getBlackRow(t,e){return this.binarizer.getBlackRow(t,e)}getBlackMatrix(){return null!==this.matrix&&void 0!==this.matrix||(this.matrix=this.binarizer.getBlackMatrix()),this.matrix}isCropSupported(){return this.binarizer.getLuminanceSource().isCropSupported()}crop(t,e,r,n){const i=this.binarizer.getLuminanceSource().crop(t,e,r,n);return new l(this.binarizer.createBinarizer(i))}isRotateSupported(){return this.binarizer.getLuminanceSource().isRotateSupported()}rotateCounterClockwise(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise();return new l(this.binarizer.createBinarizer(t))}rotateCounterClockwise45(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise45();return new l(this.binarizer.createBinarizer(t))}toString(){try{return this.getBlackMatrix().toString()}catch(t){return""}}}class h extends s{static getChecksumInstance(){return new h}}h.kind="ChecksumException";class u{constructor(t){this.source=t}getLuminanceSource(){return this.source}getWidth(){return this.source.getWidth()}getHeight(){return this.source.getHeight()}}class d{static arraycopy(t,e,r,n,i){for(;i--;)r[n++]=t[e++]}static currentTimeMillis(){return Date.now()}}class f extends s{}f.kind="IndexOutOfBoundsException";class g extends f{constructor(t=undefined,e=undefined){super(e),this.index=t,this.message=e}}g.kind="ArrayIndexOutOfBoundsException";class w{static fill(t,e){for(let r=0,n=t.length;r<n;r++)t[r]=e}static fillWithin(t,e,r,n){w.rangeCheck(t.length,e,r);for(let i=e;i<r;i++)t[i]=n}static rangeCheck(t,e,r){if(e>r)throw new c("fromIndex("+e+") > toIndex("+r+")");if(e<0)throw new g(e);if(r>t)throw new g(r)}static asList(...t){return t}static create(t,e,r){return Array.from({length:t}).map((t=>Array.from({length:e}).fill(r)))}static createInt32Array(t,e,r){return Array.from({length:t}).map((t=>Int32Array.from({length:e}).fill(r)))}static equals(t,e){if(!t)return!1;if(!e)return!1;if(!t.length)return!1;if(!e.length)return!1;if(t.length!==e.length)return!1;for(let r=0,n=t.length;r<n;r++)if(t[r]!==e[r])return!1;return!0}static hashCode(t){if(null===t)return 0;let e=1;for(const r of t)e=31*e+r;return e}static fillUint8Array(t,e){for(let r=0;r!==t.length;r++)t[r]=e}static copyOf(t,e){return t.slice(0,e)}static copyOfUint8Array(t,e){if(t.length<=e){const r=new Uint8Array(e);return r.set(t),r}return t.slice(0,e)}static copyOfRange(t,e,r){const n=r-e,i=new Int32Array(n);return d.arraycopy(t,e,i,0,n),i}static binarySearch(t,e,r){void 0===r&&(r=w.numberComparator);let n=0,i=t.length-1;for(;n<=i;){const o=i+n>>1,s=r(e,t[o]);if(s>0)n=o+1;else{if(!(s<0))return o;i=o-1}}return-n-1}static numberComparator(t,e){return t-e}}class m{static numberOfTrailingZeros(t){let e;if(0===t)return 32;let r=31;return e=t<<16,0!==e&&(r-=16,t=e),e=t<<8,0!==e&&(r-=8,t=e),e=t<<4,0!==e&&(r-=4,t=e),e=t<<2,0!==e&&(r-=2,t=e),r-(t<<1>>>31)}static numberOfLeadingZeros(t){if(0===t)return 32;let e=1;return t>>>16==0&&(e+=16,t<<=16),t>>>24==0&&(e+=8,t<<=8),t>>>28==0&&(e+=4,t<<=4),t>>>30==0&&(e+=2,t<<=2),e-=t>>>31,e}static toHexString(t){return t.toString(16)}static toBinaryString(t){return String(parseInt(String(t),2))}static bitCount(t){return t=(t=(858993459&(t-=t>>>1&1431655765))+(t>>>2&858993459))+(t>>>4)&252645135,63&(t+=t>>>8)+(t>>>16)}static truncDivision(t,e){return Math.trunc(t/e)}static parseInt(t,e=undefined){return parseInt(t,e)}}m.MIN_VALUE_32_BITS=-2147483648,m.MAX_VALUE=Number.MAX_SAFE_INTEGER;class p{constructor(t,e){void 0===t?(this.size=0,this.bits=new Int32Array(1)):(this.size=t,this.bits=null==e?p.makeArray(t):e)}getSize(){return this.size}getSizeInBytes(){return Math.floor((this.size+7)/8)}ensureCapacity(t){if(t>32*this.bits.length){const e=p.makeArray(t);d.arraycopy(this.bits,0,e,0,this.bits.length),this.bits=e}}get(t){return 0!=(this.bits[Math.floor(t/32)]&1<<(31&t))}set(t){this.bits[Math.floor(t/32)]|=1<<(31&t)}fLine truncated
|
||||||
Reference in new issue
Block a user