Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes

This commit is contained in:
spaethf committed 2026-09-26 23:24:14 +02:00
1 parent cba9414e92
commit 3404fb9875
25 files changed
+5106 -744

No files matched your search

@@ -44,85 +44,54 @@ else
</div>
</div>
<div class="stats-bar">
<div class="stat">
<span class="stat-value">@allLocations.Count</span>
<span class="stat-label">Gesamt</span>
<div class="toolbar">
<div class="stat-chips">
<button class="stat-chip @(statusFilter == null && selectedArea == null ? "active" : "")"
@onclick="ResetFilters" title="Alle Filter zurücksetzen">
<span class="chip-value">@allLocations.Count</span> Gesamt
</button>
<button class="stat-chip available @(statusFilter == StatusAvailable ? "active" : "")"
@onclick="FilterByStatusAvailable" title="Nur freie Plätze anzeigen">
<span class="chip-value">@allLocations.Count(l => !l.IsOccupied)</span> Frei
</button>
<button class="stat-chip occupied @(statusFilter == StatusOccupied ? "active" : "")"
@onclick="FilterByStatusOccupied" title="Nur belegte Plätze anzeigen">
<span class="chip-value">@allLocations.Count(l => l.IsOccupied)</span> Belegt
</button>
<button class="stat-chip lager @(selectedArea == AreaType.Lager ? "active" : "")"
@onclick="() => FilterByArea(selectedArea == AreaType.Lager ? null : AreaType.Lager)" title="Nur Lager anzeigen">
<span class="chip-value">@allLocations.Count(l => l.Area == AreaType.Lager)</span> 🏭 Lager
</button>
<button class="stat-chip werkstatt @(selectedArea == AreaType.Werkstatt ? "active" : "")"
@onclick="() => FilterByArea(selectedArea == AreaType.Werkstatt ? null : AreaType.Werkstatt)" title="Nur Werkstatt anzeigen">
<span class="chip-value">@allLocations.Count(l => l.Area == AreaType.Werkstatt)</span> 🔧 Werkstatt
</button>
</div>
<div class="stat available">
<span class="stat-value">@allLocations.Count(l => !l.IsOccupied)</span>
<span class="stat-label">Frei</span>
</div>
<div class="stat occupied">
<span class="stat-value">@allLocations.Count(l => l.IsOccupied)</span>
<span class="stat-label">Belegt</span>
</div>
<div class="stat lager">
<span class="stat-value">@allLocations.Count(l => l.Area == AreaType.Lager)</span>
<span class="stat-label">🏭 Lager</span>
</div>
<div class="stat werkstatt">
<span class="stat-value">@allLocations.Count(l => l.Area == AreaType.Werkstatt)</span>
<span class="stat-label">🔧 Werkstatt</span>
</div>
</div>
<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 class="filter-group">
<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="toolbar-controls">
<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" />
</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>
@@ -456,7 +425,85 @@ else
{
var ts = selectedLocation.OccupiedTireStack;
<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-header">
<span class="stored-customer">
@@ -486,6 +533,7 @@ else
Eingelagert am @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy") • @ts.DaysStored Tage
</div>
</div>
}
</div>
}
@@ -677,6 +725,11 @@ else
private StorageLocation? selectedLocation;
private string? detailsError;
// Tire stack inline editing
private bool isEditingTireStack;
private bool isSavingTireStack;
private TireStack? editTireStack;
// Delete confirmation
private bool showDeleteConfirm;
private StorageLocation? locationToDelete;
@@ -820,8 +873,44 @@ else
ApplyFilter();
}
private void FilterByStatusAvailable() => FilterByStatus(StatusAvailable);
private void FilterByStatusOccupied() => FilterByStatus(StatusOccupied);
private void FilterByStatusAvailable() => FilterByStatus(statusFilter == StatusAvailable ? null : StatusAvailable);
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()
{
@@ -869,6 +958,8 @@ else
private async Task ShowLocationDetails(StorageLocation location)
{
detailsError = null;
isEditingTireStack = false;
editTireStack = null;
selectedLocation = await StorageLocationService.GetByIdAsync(location.Id);
}
@@ -876,6 +967,78 @@ else
{
selectedLocation = 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()
@@ -39,8 +39,8 @@
.page-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
@media (min-width: 480px) {
@@ -50,7 +50,7 @@
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.25rem;
margin-bottom: 0.75rem;
}
}
@@ -134,137 +134,156 @@
}
/* ========================================
Statistics Bar - Responsive
Compact Toolbar (Stats + Filter combined)
======================================== */
.stats-bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
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 {
.toolbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
background: rgba(28, 28, 30, 0.4);
gap: 0.5rem;
margin-bottom: 0.75rem;
padding: 0.6rem 0.75rem;
background: rgba(28, 28, 30, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: var(--radius-lg);
border: 0.5px solid var(--border-light);
}
@media (min-width: 768px) {
.filter-bar {
@media (min-width: 1024px) {
.toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-end;
gap: 1.25rem;
margin-bottom: 1.25rem;
gap: 0.75rem;
}
}
.filter-group {
display: flex;
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 {
/* Stat chips - clickable, act as filters */
.stat-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* Row Filter */
.row-filter {
min-width: 120px;
.stat-chip {
display: inline-flex;
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 */
@@ -323,14 +342,15 @@
}
.search-box .form-control {
padding: 10px 14px;
padding: 7px 12px;
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-base);
font-size: var(--text-sm);
width: 100%;
min-height: 34px;
}
.search-box .form-control:focus {
@@ -400,8 +420,8 @@
Area Section (Grid View)
======================================== */
.area-section {
margin-bottom: 1.5rem;
padding: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: rgba(28, 28, 30, 0.4);
border-radius: var(--radius-xl);
border: 0.5px solid var(--border-light);
@@ -409,8 +429,8 @@
@media (min-width: 480px) {
.area-section {
padding: 1.25rem;
margin-bottom: 2rem;
padding: 1rem;
margin-bottom: 1.25rem;
}
}
@@ -424,22 +444,32 @@
.area-title {
font-family: var(--font-family-base);
font-size: var(--text-lg);
font-size: var(--text-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
margin: 0 0 0.6rem 0;
padding-bottom: 0.4rem;
border-bottom: 0.5px solid var(--border-default);
}
.row-section {
margin-bottom: 1rem;
margin-bottom: 0.6rem;
}
.row-section:last-child {
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 {
display: flex;
align-items: center;
@@ -447,8 +477,8 @@
width: 100%;
background: none;
border: none;
padding: 0.35rem 0;
margin: 0 0 0.75rem 0;
padding: 0.25rem 0.25rem;
margin: 0 0 0.4rem 0;
cursor: pointer;
text-align: left;
border-radius: 6px;
@@ -500,7 +530,7 @@
.location-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
gap: 6px;
}
@media (min-width: 400px) {
@@ -511,14 +541,14 @@
@media (min-width: 480px) {
.location-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
}
@media (min-width: 768px) {
.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);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-md);
padding: 10px;
padding: 8px;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
position: relative;
min-height: 70px;
min-height: 58px;
}
.location-card:hover {
@@ -1144,6 +1174,83 @@
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 {
padding: 0;
}
+566 -39
View File
@@ -5,6 +5,7 @@
@inject IStorageLocationService StorageLocationService
@inject ILocationCodeService LocationCodeService
@inject ITireStackService TireStackService
@inject ITireReportService TireReportService
@inject ICatalogService CatalogService
@inject IFileStorageService FileStorageService
@inject UserSessionService UserSession
@@ -474,6 +475,42 @@ else
</div>
}
@if (!isInventoryMode)
{
@* QR report scan *@
<div class="report-scan-bar">
@if (pendingReport == null)
{
<div class="report-scan-text">
<span class="report-scan-icon" aria-hidden="true">📷</span>
<span>Profiltiefen-Report (Launch TTS01) per QR-Code übernehmen</span>
</div>
<button type="button" class="btn btn-outline" @onclick="OpenQrScanner" disabled="@isLoadingReport">
@if (isLoadingReport)
{
<span class="spinner"></span>
<span>Report wird geladen…</span>
}
else
{
<span>QR-Code scannen</span>
}
</button>
}
else
{
<div class="report-attached">
<div class="report-attached-head">
<span class="report-scan-icon" aria-hidden="true">📋</span>
<strong>Report wird mit dem neuen Stapel gespeichert</strong>
<button type="button" class="btn-link" @onclick="RemovePendingReport">Entfernen</button>
</div>
@ReportSummaryFragment(pendingReport)
</div>
}
</div>
}
@* Tire/Felgen Form *@
<div class="tire-form">
@if (!isFelgenCategory)
@@ -506,6 +543,10 @@ else
else
{
<TireSizeInput Value="@tireStack.TireSize" ValueChanged="OnTireSizeChanged" />
@if (tireSizeFromVehicle && !string.IsNullOrEmpty(tireStack.TireSize))
{
<span class="field-hint">🚗 Vom Fahrzeug übernommen</span>
}
}
</div>
@@ -657,7 +698,7 @@ else
@* Step 3: Storage Location *@
@if (currentStep == 3)
{
<div class="checkin-card">
<div class="checkin-card wide">
<div class="selected-info-bar">
@if (isInventoryMode)
{
@@ -676,32 +717,104 @@ else
<h2>Lagerplatz auswählen</h2>
<div class="location-selector">
@* Area Filter *@
<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>
@* 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++)
@if (selectedLocation != null)
{
<div class="checkin-summary">
<h3 class="summary-title">📋 Zusammenfassung</h3>
<div class="summary-grid">
@if (isInventoryMode)
{
<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>
@* Location Grid *@
@@ -720,7 +833,7 @@ else
<h3 class="area-title">
@(areaGroup.Key == AreaType.Lager ? "🏭" : "🔧") @areaGroup.Key.GetDisplayName()
</h3>
@foreach (var rowGroup in areaGroup.GroupBy(l => l.Row).OrderBy(g => g.Key))
{
<div class="row-section">
@@ -742,16 +855,6 @@ else
}
}
</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))
@@ -873,6 +976,159 @@ else
</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 {
@@ -923,6 +1179,179 @@ else
private string? zoomImageUrl;
private string? zoomImageTitle;
// QR report
private enum ReportAssignMode { Existing, New }
private bool showQrScanner;
private bool isLoadingReport;
private TireReportData? scannedReport;
private TireReportData? pendingReport;
private ReportAssignMode reportMode = ReportAssignMode.New;
private List<TireStack> customerStacksForReport = new();
private Guid? reportTargetStackId;
private string? reportError;
private TireStack? reportAttachedTo;
private const int NotesMaxLength = 1000;
private static string RatingClass(TireMeasureRating r) => r switch
{
TireMeasureRating.Ok => "ok",
TireMeasureRating.Warning => "warn",
TireMeasureRating.Critical => "crit",
_ => "none"
};
private static string RatingClass(TireReportData r, decimal? mm) => RatingClass(TireInspection.RateDepth(mm, r.CriticalThreshold, r.WarningThreshold));
private RenderFragment<TireReportData> ReportSummaryFragment => report => __builder =>
{
<div class="report-summary">
@if (!string.IsNullOrEmpty(report.LicensePlate)) { <span class="report-chip">🚗 @report.LicensePlate</span> }
@if (report.Mileage.HasValue) { <span class="report-chip">@report.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km</span> }
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFL)">VL @Fmt(report.TreadDepthFL)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFR)">VR @Fmt(report.TreadDepthFR)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRL)">HL @Fmt(report.TreadDepthRL)</span>
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRR)">HR @Fmt(report.TreadDepthRR)</span>
</div>
};
private static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
private void OpenQrScanner()
{
reportError = null;
showQrScanner = true;
}
private async Task OnQrScanned(string text)
{
showQrScanner = false;
if (selectedCustomer == null)
{
errorMessage = "Bitte zuerst einen Kunden auswählen.";
return;
}
if (!TireReportService.TryParseReportUrl(text, out var recordSn, out _))
{
errorMessage = "Der QR-Code enthält keinen gültigen Reifen-Report-Link.";
return;
}
isLoadingReport = true;
errorMessage = null;
StateHasChanged();
try
{
var report = await TireReportService.FetchAsync(recordSn);
var stacks = await TireStackService.GetByCustomerAsync(selectedCustomer.Id);
customerStacksForReport = stacks
.Where(s => s.IsActive && s.Status != TireStackStatus.PickedUp)
.OrderByDescending(s => selectedVehicle != null && s.VehicleId == selectedVehicle.Id)
.ThenByDescending(s => s.Status == TireStackStatus.Stored)
.ThenByDescending(s => s.StoredAt)
.ToList();
reportMode = customerStacksForReport.Count > 0 ? ReportAssignMode.Existing : ReportAssignMode.New;
reportTargetStackId = customerStacksForReport.FirstOrDefault()?.Id;
reportError = null;
scannedReport = report;
}
catch (TireReportException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = $"Report konnte nicht verarbeitet werden: {ex.Message}";
}
finally
{
isLoadingReport = false;
}
}
private void SetReportMode(ReportAssignMode mode)
{
reportMode = mode;
reportError = null;
}
private void CloseReportModal()
{
scannedReport = null;
reportError = null;
}
private async Task AttachReportToExisting()
{
if (scannedReport == null || reportTargetStackId == null) return;
isProcessing = true;
reportError = null;
StateHasChanged();
try
{
scannedReport.RecomputeRatings();
await TireStackService.AddInspectionAsync(scannedReport.ToInspection(reportTargetStackId.Value, UserSession.CurrentUser?.Id));
reportAttachedTo = customerStacksForReport.FirstOrDefault(s => s.Id == reportTargetStackId.Value);
scannedReport = null;
}
catch (Exception ex)
{
reportError = $"Speichern fehlgeschlagen: {ex.Message}";
}
finally
{
isProcessing = false;
}
}
private void ApplyReportToNew()
{
if (scannedReport == null) return;
scannedReport.RecomputeRatings();
pendingReport = scannedReport;
scannedReport = null;
var de = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
var lines = new List<string>();
if (!string.IsNullOrEmpty(pendingReport.LicensePlate)) lines.Add($"Kennzeichen: {pendingReport.LicensePlate}");
if (pendingReport.Mileage.HasValue) lines.Add($"km-Stand: {pendingReport.Mileage.Value.ToString("N0", de)}");
if (pendingReport.HasTreadData)
lines.Add($"Profil VL {Fmt(pendingReport.TreadDepthFL)} / VR {Fmt(pendingReport.TreadDepthFR)} / HL {Fmt(pendingReport.TreadDepthRL)} / HR {Fmt(pendingReport.TreadDepthRR)}");
if (!string.IsNullOrEmpty(pendingReport.Recommendation)) lines.Add(pendingReport.Recommendation);
var block = string.Join("\n", lines);
if (!string.IsNullOrWhiteSpace(block))
{
var merged = string.IsNullOrWhiteSpace(tireStack.Notes) ? block : tireStack.Notes.TrimEnd() + "\n" + block;
tireStack.Notes = merged.Length <= NotesMaxLength ? merged : merged[..NotesMaxLength];
showDetailsExpanded = true;
}
if (pendingReport.HasTreadData)
{
var minDepth = new[] { pendingReport.TreadDepthFL, pendingReport.TreadDepthFR, pendingReport.TreadDepthRL, pendingReport.TreadDepthRR }
.Where(v => v.HasValue).Select(v => v!.Value).Min();
tireStack.Condition = minDepth < TireInspection.DefaultWarningThreshold ? TireCondition.Poor
: minDepth < 5m ? TireCondition.Fair
: TireCondition.Good;
}
_ = SaveDraftAsync();
}
private void RemovePendingReport()
{
pendingReport = null;
_ = SaveDraftAsync();
}
// Computed helpers
private bool isFelgenCategory => isInventoryMode && selectedCategory?.Type == CatalogItemType.Felgen;
@@ -954,8 +1383,16 @@ else
// Cleared on successful CompleteCheckin and on ResetForm.
// ------------------------------------------------------------------
private const string DraftKey = "tire-checkin";
private static readonly System.Text.Json.JsonSerializerOptions DraftJsonOptions = new()
{
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private bool _draftRestoreAttempted;
private bool _isRestoringDraft;
[SupplyParameterFromQuery(Name = "customerId")]
public Guid? PreselectedCustomerId { get; set; }
private CancellationTokenSource? _draftDebounceCts;
private CancellationTokenSource? _searchDebounceCts;
private readonly SemaphoreSlim _searchLock = new(1, 1);
@@ -965,10 +1402,37 @@ else
if (firstRender && UserSession.IsWorkerOrAdmin && !_draftRestoreAttempted)
{
_draftRestoreAttempted = true;
if (PreselectedCustomerId.HasValue)
{
await PreselectCustomerAsync(PreselectedCustomerId.Value);
return;
}
await TryRestoreDraftAsync();
}
}
private async Task PreselectCustomerAsync(Guid customerId)
{
try
{
var customer = await CustomerService.GetByIdAsync(customerId);
if (customer == null) return;
try { await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey); } catch { }
isInventoryMode = false;
await SelectCustomer(customer);
GoToStep2();
StateHasChanged();
}
catch (Exception ex)
{
errorMessage = $"Kunde konnte nicht geladen werden: {ex.Message}";
}
}
private async Task TryRestoreDraftAsync()
{
try
@@ -976,7 +1440,7 @@ else
var json = await JS.InvokeAsync<string?>("appInterop.loadDraft", DraftKey);
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;
_isRestoringDraft = true;
@@ -1012,6 +1476,8 @@ else
tireStack = draft.TireStack;
}
pendingReport = draft.PendingReport;
if (draft.SelectedLocationId.HasValue)
{
selectedLocation = allLocations.FirstOrDefault(l => l.Id == draft.SelectedLocationId.Value);
@@ -1083,10 +1549,11 @@ else
SelectedLocationId = selectedLocation?.Id,
UseCustomItemCount = useCustomItemCount,
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.setUnsaved", HasMeaningfulDraft());
}
@@ -1127,6 +1594,7 @@ else
public bool UseCustomItemCount { get; set; }
public bool ShowDetailsExpanded { get; set; }
public TireStack? TireStack { get; set; }
public TireReportData? PendingReport { get; set; }
}
private async Task LoadLocations()
@@ -1251,6 +1719,8 @@ else
selectedVehicle = null;
}
ApplyVehicleTireSize();
_ = SaveDraftAsync();
}
@@ -1258,9 +1728,26 @@ else
{
selectedVehicle = vehicle;
showAddVehicleForm = false;
ApplyVehicleTireSize();
_ = SaveDraftAsync();
}
/// <summary>
/// Übernimmt die Reifengröße des gewählten Fahrzeugs in den Reifenstapel,
/// sofern noch keine (abweichende) Größe manuell erfasst wurde.
/// </summary>
private void ApplyVehicleTireSize()
{
if (isInventoryMode) return;
if (string.IsNullOrWhiteSpace(selectedVehicle?.TireSize)) return;
if (string.IsNullOrWhiteSpace(tireStack.TireSize) || tireSizeFromVehicle)
{
OnTireSizeChanged(selectedVehicle.TireSize);
tireSizeFromVehicle = true;
}
}
private void ShowNewVehicleForm()
{
showAddVehicleForm = true;
@@ -1285,6 +1772,7 @@ else
showAddVehicleForm = false;
newVehicle = new Vehicle();
errorMessage = null;
ApplyVehicleTireSize();
}
catch (Exception ex)
{
@@ -1344,6 +1832,7 @@ else
newCustomer = new Customer();
newVehicle = new Vehicle();
errorMessage = null;
ApplyVehicleTireSize();
}
catch (Exception ex)
{
@@ -1441,6 +1930,7 @@ else
private async Task GoToStep3()
{
await LoadLocations();
locationSelectorCollapsed = selectedLocation != null;
currentStep = 3;
_ = SaveDraftAsync();
}
@@ -1451,18 +1941,27 @@ else
_ = SaveDraftAsync();
}
private bool locationSelectorCollapsed;
private void SelectLocation(StorageLocation location)
{
if (!location.IsOccupied && location.IsAvailable)
{
selectedLocation = location;
locationSelectorCollapsed = true;
_ = SaveDraftAsync();
}
}
private void ExpandLocationSelector()
{
locationSelectorCollapsed = false;
}
private void ClearSelectedLocation()
{
selectedLocation = null;
locationSelectorCollapsed = false;
}
private async Task CompleteCheckin()
@@ -1501,6 +2000,19 @@ else
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;
await ClearDraftAsync();
}
@@ -1533,9 +2045,11 @@ else
showAddVehicleForm = false;
tireStack = new TireStack();
showDetailsExpanded = false;
tireSizeFromVehicle = false;
selectedLocation = null;
selectedAreaFilter = null;
selectedRowFilter = null;
locationSelectorCollapsed = false;
createdTireStack = null;
errorMessage = null;
useCustomItemCount = false;
@@ -1544,6 +2058,13 @@ else
showImageZoom = false;
zoomImageUrl = null;
zoomImageTitle = null;
showQrScanner = false;
scannedReport = null;
pendingReport = null;
reportAttachedTo = null;
reportError = null;
customerStacksForReport.Clear();
reportTargetStackId = null;
_ = ClearDraftAsync();
}
@@ -1563,8 +2084,14 @@ else
tireStack.RimSize = isAlu ? "Alu" : "Stahl";
}
private bool tireSizeFromVehicle;
private void OnTireSizeChanged(string? value)
{
if (tireStack.TireSize != value && value != selectedVehicle?.TireSize)
{
tireSizeFromVehicle = false;
}
tireStack.TireSize = value;
// Auto-derive rim size from the R value of the tire size (e.g. "225/45 R17" -> "17 Zoll")
@@ -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 {
font-family: 'Rajdhani', sans-serif;
font-size: clamp(1.25rem, 4vw, 1.75rem);
@@ -1192,38 +1204,90 @@ select.form-control {
/* ========================================
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 {
display: flex;
gap: 6px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
@media (min-width: 480px) {
.area-filter {
gap: 8px;
margin-bottom: 1rem;
}
}
.filter-btn {
padding: 10px 12px;
padding: 6px 10px;
background: rgba(28, 28, 30, 0.8);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.6);
font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif;
font-size: 0.85rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
min-height: 44px;
min-height: 36px;
}
@media (min-width: 480px) {
.filter-btn {
padding: 10px 14px;
border-radius: 10px;
font-size: 0.9rem;
padding: 6px 12px;
font-size: 0.85rem;
}
}
@@ -1266,51 +1330,51 @@ select.form-control {
Area Section - Responsive
======================================== */
.area-section {
margin-bottom: 1.25rem;
margin-bottom: 0.75rem;
}
@media (min-width: 480px) {
.area-section {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
}
.area-title {
font-family: 'Rajdhani', sans-serif;
font-size: 1rem;
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
margin: 0 0 0.5rem 0;
margin: 0 0 0.4rem 0;
}
@media (min-width: 480px) {
.area-title {
font-size: 1.1rem;
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
margin: 0 0 0.5rem 0;
}
}
.row-section {
margin-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
@media (min-width: 480px) {
.row-section {
margin-bottom: 1rem;
margin-bottom: 0.6rem;
}
}
.row-label {
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
display: block;
margin-bottom: 0.5rem;
margin-bottom: 0.3rem;
}
@media (min-width: 480px) {
.row-label {
font-size: 0.85rem;
font-size: 0.8rem;
}
}
@@ -1319,50 +1383,49 @@ select.form-control {
======================================== */
.location-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
@media (min-width: 400px) {
.location-grid {
grid-template-columns: repeat(4, 1fr);
gap: 8px;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
}
@media (min-width: 768px) {
.location-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
}
}
@media (min-width: 1024px) {
.location-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(85px, 1fr));
gap: 8px;
}
}
.location-tile {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
gap: 1px;
background: rgba(0, 0, 0, 0.3);
border: 0.5px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
min-height: 60px;
min-height: 44px;
padding: 4px 2px;
}
@media (min-width: 480px) {
.location-tile {
gap: 4px;
border-radius: 10px;
min-height: 70px;
gap: 2px;
min-height: 48px;
}
}
@@ -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 {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
font-size: 1.25rem;
@@ -2237,3 +2404,390 @@ select.form-control {
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"
@using Lagerbestand.Components.Shared
@implements IDisposable
@implements IAsyncDisposable
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject IStorageLocationService StorageLocationService
@inject UserSessionService UserSession
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
@@ -169,6 +170,164 @@ else
</div>
}
@* Tire Swap Panel *@
@if (swapOutgoing != null)
{
<section class="swap-panel" aria-labelledby="swap-heading">
<header class="swap-header">
<h2 id="swap-heading"><span aria-hidden="true">🔄</span> Reifenwechsel</h2>
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelSwap" disabled="@isProcessing">Abbrechen</button>
</header>
<div class="swap-columns">
@* Outgoing *@
<div class="swap-box outgoing">
<h3 class="swap-box-title"><span aria-hidden="true">⬆️</span> Herausgabe (wird montiert)</h3>
<dl class="swap-rows">
<div class="swap-row"><dt>Ticket</dt><dd class="mono">@swapOutgoing.TicketNumber</dd></div>
<div class="swap-row"><dt>Kunde</dt><dd>@swapOutgoing.Customer?.FullName</dd></div>
@if (swapOutgoing.Vehicle != null)
{
<div class="swap-row"><dt>Fahrzeug</dt><dd>@swapOutgoing.Vehicle.Make @swapOutgoing.Vehicle.Model</dd></div>
}
<div class="swap-row"><dt>Saison</dt><dd>@SeasonLabel(swapOutgoing.Season)</dd></div>
<div class="swap-row"><dt>Größe</dt><dd class="mono">@(swapOutgoing.TireSize ?? "-")</dd></div>
<div class="swap-row"><dt>Marke / Modell</dt><dd>@(string.IsNullOrWhiteSpace(swapOutgoing.TireBrand) && string.IsNullOrWhiteSpace(swapOutgoing.TireModel) ? "-" : $"{swapOutgoing.TireBrand} {swapOutgoing.TireModel}".Trim())</dd></div>
<div class="swap-row"><dt>Lagerplatz</dt><dd>
@if (swapOutgoing.StorageLocation != null)
{
<LocationDisplay Code="@swapOutgoing.StorageLocation.Code" DisplayMode="LocationDisplayMode.Compact" Size="LocationDisplaySize.Small" />
}
else
{
<span>-</span>
}
</dd></div>
</dl>
</div>
@* Incoming *@
<div class="swap-box incoming">
<h3 class="swap-box-title"><span aria-hidden="true">⬇️</span> Einlagerung (kommt vom Fahrzeug)</h3>
<div class="swap-tabs" role="tablist">
<button type="button" class="swap-tab @(swapUseHistory ? "active" : "")" role="tab" aria-selected="@swapUseHistory"
@onclick="() => swapUseHistory = true" disabled="@(swapHistory.Count == 0)">
Aus Historie (@swapHistory.Count)
</button>
<button type="button" class="swap-tab @(!swapUseHistory ? "active" : "")" role="tab" aria-selected="@(!swapUseHistory)"
@onclick="() => swapUseHistory = false">
Neu erfassen
</button>
</div>
@if (swapUseHistory)
{
<div class="history-list" role="list">
@foreach (var h in swapHistory)
{
var isSameSeason = h.Season == swapOutgoing.Season;
<button type="button" class="history-item @(swapSelectedHistory?.Id == h.Id ? "selected" : "") @(isSameSeason ? "same-season" : "")"
role="listitem" @onclick="() => swapSelectedHistory = h">
<span class="history-season">@SeasonIcon(h.Season)</span>
<span class="history-main">
<span class="mono">@h.TicketNumber</span>
<span class="history-detail">@(h.TireSize ?? "-") @(string.IsNullOrWhiteSpace(h.TireBrand) ? "" : $"| {h.TireBrand} {h.TireModel}".Trim()) @(h.WithRims ? "| mit Felgen" : "")</span>
<span class="history-meta">@h.StatusText @(h.PickedUpAt.HasValue ? $"seit {h.PickedUpAt.Value.ToLocalTime():dd.MM.yyyy}" : "")</span>
</span>
@if (isSameSeason)
{
<span class="history-warn" title="Gleiche Saison wie Herausgabe">⚠️</span>
}
</button>
}
</div>
}
else
{
<div class="form-grid">
<div class="form-group">
<label for="swap-season">Saison</label>
<select id="swap-season" class="form-control" @bind="swapNew.Season">
<option value="@TireSeason.Sommer">☀️ Sommer</option>
<option value="@TireSeason.Winter">❄️ Winter</option>
<option value="@TireSeason.Allrad">🔄 Allrad</option>
</select>
</div>
<div class="form-group">
<label for="swap-size">Reifengröße</label>
<input id="swap-size" class="form-control" @bind="swapNew.TireSize" placeholder="z.B. 225/45R17" />
</div>
<div class="form-group">
<label for="swap-brand">Marke</label>
<input id="swap-brand" class="form-control" @bind="swapNew.TireBrand" placeholder="z.B. Michelin" />
</div>
<div class="form-group">
<label for="swap-model">Modell</label>
<input id="swap-model" class="form-control" @bind="swapNew.TireModel" placeholder="z.B. Pilot Sport 4" />
</div>
<div class="form-group">
<label for="swap-rims">Felgen</label>
<select id="swap-rims" class="form-control" @bind="swapNew.WithRims">
<option value="true">Mit Felgen</option>
<option value="false">Ohne Felgen</option>
</select>
</div>
<div class="form-group">
<label for="swap-condition">Zustand</label>
<select id="swap-condition" class="form-control" @bind="swapNew.Condition">
<option value="@TireCondition.Excellent">Sehr gut</option>
<option value="@TireCondition.Good">Gut</option>
<option value="@TireCondition.Fair">Ausreichend</option>
<option value="@TireCondition.Poor">Mangelhaft</option>
<option value="@TireCondition.Critical">Kritisch</option>
</select>
</div>
<div class="form-group form-group-wide">
<label for="swap-notes">Notizen</label>
<textarea id="swap-notes" class="form-control" rows="2" @bind="swapNew.Notes"></textarea>
</div>
</div>
}
</div>
</div>
@* Target location *@
<div class="swap-location">
<span class="swap-location-label">Lagerplatz für Einlagerung:</span>
@if (swapTargetLocation != null)
{
<LocationDisplay Code="@swapTargetLocation.Code" DisplayMode="LocationDisplayMode.Compact" Size="LocationDisplaySize.Small" />
@if (swapTargetLocation.Id == swapOutgoing.StorageLocationId)
{
<span class="swap-location-hint">(frei werdender Platz)</span>
}
else
{
<button type="button" class="btn btn-sm btn-outline" @onclick="ResetTargetLocation">Zurück zum alten Platz</button>
}
}
else
{
<span class="swap-location-hint">Kein Lagerplatz gewählt</span>
}
<button type="button" class="btn btn-sm btn-outline" @onclick="() => showLocationPicker = true">
<span aria-hidden="true">▦</span> Anderen Platz wählen
</button>
</div>
<div class="swap-actions">
<button type="button" class="btn btn-primary btn-lg w-100" @onclick="ProcessSwap" disabled="@(isProcessing || !CanSwap)">
@if (isProcessing)
{
<span class="spinner"></span>
}
<span>Reifenwechsel durchführen</span>
</button>
</div>
</section>
}
@* Search Results *@
@if (searchResults.Count > 0)
{
@@ -182,7 +341,7 @@ else
{
var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id);
<div class="tireset-card @(isAlreadyScanned ? "already-scanned" : "")"
@onclick="() => AddToScannedList(ts)" role="listitem">
@onclick="() => OnResultClicked(ts)" role="listitem">
<div class="tireset-header">
<span class="ticket-number">@ts.TicketNumber</span>
<span class="status-badge @ts.Status.ToString().ToLower()">
@@ -235,6 +394,74 @@ else
</div>
}
@* Storage location picker (rack view) for swap *@
@if (swapOutgoing != null)
{
<StorageLocationPickerModal @bind-IsOpen="showLocationPicker"
SelectedLocationId="@swapTargetLocation?.Id"
HighlightLocationId="@swapOutgoing.StorageLocationId"
Subtitle="@($"Einlagerung für {swapOutgoing.Customer?.FullName} – {SeasonLabel(swapUseHistory ? (swapSelectedHistory?.Season ?? swapNew.Season) : swapNew.Season)}")"
OnSelected="OnTargetLocationSelected" />
}
@* Action Chooser Modal *@
@if (actionCandidate != null)
{
<div class="success-overlay" @onclick="CloseActionChooser">
<div class="success-modal action-modal" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="action-title">
<h2 id="action-title">Aktion wählen</h2>
<p class="action-subject">
<strong>@actionCandidate.Customer?.FullName</strong>
@if (actionCandidate.Vehicle != null)
{
<span class="vehicle-info">@actionCandidate.Vehicle.Make @actionCandidate.Vehicle.Model</span>
}
<br />
<span class="mono">@actionCandidate.TicketNumber</span> · @SeasonLabel(actionCandidate.Season) @(actionCandidate.TireSize != null ? $"· {actionCandidate.TireSize}" : "")
</p>
<div class="action-buttons">
<button type="button" class="action-btn" @onclick="ChoosePickup">
<span class="action-icon" aria-hidden="true">📤</span>
<span class="action-label">Abholen</span>
<span class="action-desc">Kunde nimmt die Reifen mit</span>
</button>
<button type="button" class="action-btn swap" @onclick="ChooseSwap">
<span class="action-icon" aria-hidden="true">🔄</span>
<span class="action-label">Reifenwechsel</span>
<span class="action-desc">Satz montieren, anderen Satz einlagern</span>
</button>
</div>
<button type="button" class="btn btn-outline" @onclick="CloseActionChooser">Abbrechen</button>
</div>
</div>
}
@* Swap Success Modal *@
@if (swapResult != null)
{
<div class="success-overlay" @onclick="CloseSwapSuccess">
<div class="success-modal" @onclick:stopPropagation="true">
<div class="success-icon">✓</div>
<h2>Reifenwechsel erfolgreich!</h2>
<div class="pickup-list" role="list">
<div class="pickup-item" role="listitem">
<span class="ticket">⬆️ @swapResult.Outgoing.TicketNumber</span>
<span class="info">@SeasonLabel(swapResult.Outgoing.Season) montiert</span>
</div>
<div class="pickup-item" role="listitem">
<span class="ticket">⬇️ @swapResult.Incoming.TicketNumber</span>
<span class="info">@SeasonLabel(swapResult.Incoming.Season) eingelagert auf <strong>@swapResult.NewLocationCode</strong></span>
</div>
</div>
@if (swapResult.FreedLocationCode != null && swapResult.FreedLocationCode != swapResult.NewLocationCode)
{
<p class="success-detail">Lagerplatz <strong>@swapResult.FreedLocationCode</strong> ist wieder frei.</p>
}
<button class="btn btn-primary" @onclick="CloseSwapSuccess">OK</button>
</div>
</div>
}
@* Success Modal *@
@if (showSuccess)
{
@@ -297,6 +524,200 @@ else
private CancellationTokenSource? _searchDebounceCts;
private readonly SemaphoreSlim _searchLock = new(1, 1);
// Action chooser / swap flow
private TireStack? actionCandidate;
private TireStack? swapOutgoing;
private List<TireStack> swapHistory = new();
private TireStack? swapSelectedHistory;
private bool swapUseHistory;
private TireStack swapNew = new();
private bool showLocationPicker;
private StorageLocation? swapTargetLocation;
private Guid? swapTargetLocationId => swapTargetLocation?.Id;
private SwapResult? swapResult;
private bool CanSwap => swapOutgoing != null && (swapUseHistory ? swapSelectedHistory != null : !string.IsNullOrWhiteSpace(swapNew.TireSize));
[SupplyParameterFromQuery(Name = "stackId")]
public Guid? PreselectedStackId { get; set; }
protected override async Task OnInitializedAsync()
{
if (!PreselectedStackId.HasValue || !UserSession.IsWorkerOrAdmin) return;
try
{
var stack = await TireStackService.GetByIdAsync(PreselectedStackId.Value);
if (stack == null)
{
errorMessage = "Der gewählte Reifenstapel wurde nicht gefunden.";
return;
}
searchResults = new List<TireStack> { stack };
OnResultClicked(stack);
}
catch (Exception ex)
{
errorMessage = $"Reifenstapel konnte nicht geladen werden: {ex.Message}";
}
}
private static string SeasonIcon(TireSeason s) => s == TireSeason.Sommer ? "☀️" : s == TireSeason.Winter ? "❄️" : "🔄";
private static string SeasonLabel(TireSeason s) => s == TireSeason.Sommer ? "☀️ Sommer" : s == TireSeason.Winter ? "❄️ Winter" : "🔄 Allrad";
private void OnResultClicked(TireStack ts)
{
if (ts.Status != TireStackStatus.Stored)
{
errorMessage = $"'{ts.TicketNumber}' ist nicht eingelagert";
return;
}
// Inventory stacks and already selected stacks toggle directly
if (ts.IsInventory || scannedTireStacks.Any(s => s.Id == ts.Id))
{
AddToScannedList(ts);
return;
}
actionCandidate = ts;
errorMessage = null;
}
private void CloseActionChooser() => actionCandidate = null;
private void ChoosePickup()
{
if (actionCandidate == null) return;
AddToScannedList(actionCandidate);
actionCandidate = null;
}
private async Task ChooseSwap()
{
if (actionCandidate == null) return;
var ts = actionCandidate;
actionCandidate = null;
await StartSwap(ts);
}
private async Task StartSwap(TireStack outgoing)
{
swapOutgoing = outgoing;
swapResult = null;
showLocationPicker = false;
swapTargetLocation = outgoing.StorageLocation;
swapSelectedHistory = null;
errorMessage = null;
successMessage = null;
var oppositeSeason = outgoing.Season == TireSeason.Sommer ? TireSeason.Winter
: outgoing.Season == TireSeason.Winter ? TireSeason.Sommer
: TireSeason.Allrad;
swapNew = new TireStack
{
Season = oppositeSeason,
TireSize = outgoing.Vehicle?.TireSize ?? outgoing.TireSize,
WithRims = outgoing.WithRims,
Condition = TireCondition.Good,
CustomerId = outgoing.CustomerId,
VehicleId = outgoing.VehicleId
};
await _searchLock.WaitAsync();
try
{
var history = outgoing.CustomerId.HasValue
? await TireStackService.GetByCustomerAsync(outgoing.CustomerId.Value)
: new List<TireStack>();
swapHistory = history
.Where(h => h.Id != outgoing.Id && (h.Status == TireStackStatus.OnVehicle || h.Status == TireStackStatus.PickedUp))
.OrderBy(h => h.Season == outgoing.Season)
.ThenByDescending(h => h.PickedUpAt)
.ToList();
}
catch (Exception ex)
{
swapHistory = new();
errorMessage = $"Historie konnte nicht geladen werden: {ex.Message}";
}
finally
{
_searchLock.Release();
}
swapUseHistory = swapHistory.Count > 0;
swapSelectedHistory = swapHistory.FirstOrDefault(h => h.Season != outgoing.Season) ?? swapHistory.FirstOrDefault();
}
private void OnTargetLocationSelected(StorageLocation loc)
{
swapTargetLocation = loc;
}
private void ResetTargetLocation()
{
swapTargetLocation = swapOutgoing?.StorageLocation;
}
private void CancelSwap()
{
swapOutgoing = null;
swapHistory.Clear();
swapSelectedHistory = null;
showLocationPicker = false;
swapTargetLocation = null;
}
private async Task ProcessSwap()
{
if (swapOutgoing == null || !CanSwap) return;
isProcessing = true;
errorMessage = null;
StateHasChanged();
await _searchLock.WaitAsync();
try
{
var incoming = swapUseHistory
? new TireStack
{
Id = swapSelectedHistory!.Id,
Condition = swapSelectedHistory.Condition,
Notes = swapSelectedHistory.Notes,
VehicleId = swapOutgoing.VehicleId
}
: swapNew;
var targetLocation = swapTargetLocationId == swapOutgoing.StorageLocationId ? null : swapTargetLocationId;
swapResult = await TireStackService.SwapAsync(
swapOutgoing.Id,
incoming,
targetLocation,
UserSession.CurrentUser?.Id);
CancelSwap();
searchResults.Clear();
searchTerm = "";
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Reifenwechsel: {ex.Message}";
}
finally
{
_searchLock.Release();
isProcessing = false;
}
}
private void CloseSwapSuccess() => swapResult = null;
private async Task SearchTireStacks()
{
if (string.IsNullOrWhiteSpace(searchTerm) || searchTerm.Length < 2)
@@ -435,10 +856,11 @@ else
freedLocations.Clear();
}
public void Dispose()
public ValueTask DisposeAsync()
{
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
_searchLock.Dispose();
return ValueTask.CompletedTask;
}
}
@@ -712,6 +712,363 @@
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
======================================== */
@@ -726,7 +1083,8 @@
.checkout-card,
.results-section,
.scanned-section {
.scanned-section,
.swap-panel {
max-width: 600px;
}
}
@@ -738,7 +1096,8 @@
.checkout-card,
.results-section,
.scanned-section {
.scanned-section,
.swap-panel {
max-width: 700px;
}
}
+515 -222
View File
@@ -40,120 +40,101 @@ else
</div>
</header>
@* Statistics *@
<section class="stats-grid" aria-label="Lagerstatistik">
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.TotalStacksStored</span>
<span class="stat-label">Stapel (@stats.TotalTiresStored Reifen)</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.CustomerStackCount</span>
<span class="stat-label"><span aria-hidden="true">👤</span> Kundenstapel</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.InventoryStackCount</span>
<span class="stat-label"><span aria-hidden="true">📦</span> Lagerbestand</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.AvailableLocations</span>
<span class="stat-label">Freie Plätze</span>
</article>
@* Compact Toolbar: Stats as clickable filter chips + controls *@
<section class="toolbar" aria-label="Statistik, Suche und Filter">
<div class="stat-chips" role="tablist" aria-label="Filter nach Typ">
<button class="stat-chip @(typeFilter == null ? "active" : "")"
@onclick="() => SetTypeFilter(null)"
role="tab" aria-selected="@(typeFilter == null)"
title="Alle Stapel anzeigen">
<span class="chip-value">@stats.TotalStacksStored</span> Stapel (@stats.TotalTiresStored Reifen)
</button>
<button class="stat-chip customer @(typeFilter == false ? "active" : "")"
@onclick="() => SetTypeFilter(typeFilter == false ? null : false)"
role="tab" aria-selected="@(typeFilter == false)"
title="Nur Kundenstapel anzeigen">
<span class="chip-value">@stats.CustomerStackCount</span> <span aria-hidden="true">👤</span> Kunden
</button>
<button class="stat-chip inventory @(typeFilter == true ? "active" : "")"
@onclick="() => SetTypeFilter(typeFilter == true ? null : true)"
role="tab" aria-selected="@(typeFilter == true)"
title="Nur Lagerbestand anzeigen">
<span class="chip-value">@stats.InventoryStackCount</span> <span aria-hidden="true">📦</span> Lager
</button>
<span class="stat-chip static" title="Freie Lagerplätze">
<span class="chip-value free">@stats.AvailableLocations</span> Frei
</span>
</div>
<div class="toolbar-controls">
<div class="search-box">
<label for="search-input" class="visually-hidden">Suche</label>
<input type="search"
id="search-input"
class="search-input"
placeholder="🔍 Suchen (Name, Fahrzeug, Ticket-Nr.)..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="FilterTireStacks"
aria-describedby="search-hint" />
<span id="search-hint" class="visually-hidden">Durchsucht Namen, Fahrzeuge, Kategorien und Ticket-Nummern</span>
</div>
<fieldset class="filter-buttons">
<legend class="visually-hidden">Nach Saison filtern</legend>
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Sommer ? null : TireSeason.Sommer)"
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
aria-label="Sommerreifen" title="Sommerreifen">
<span aria-hidden="true">☀️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Winter ? null : TireSeason.Winter)"
aria-pressed="@(seasonFilter == TireSeason.Winter)"
aria-label="Winterreifen" title="Winterreifen">
<span aria-hidden="true">❄️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Allrad ? null : TireSeason.Allrad)"
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
aria-label="Ganzjahresreifen" title="Ganzjahresreifen">
<span aria-hidden="true">🔄</span>
</button>
</fieldset>
@if (typeFilter == true && categories.Count > 0)
{
<select id="category-select" class="form-control category-select" @bind="selectedCategoryId" @bind:after="FilterTireStacks" title="Kategorie auswählen">
<option value="">Alle Kategorien</option>
@foreach (var cat in categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</select>
}
<div class="view-toggle" role="tablist" aria-label="Ansicht wählen">
<button class="view-btn @(showGridView ? "" : "active")"
@onclick="() => showGridView = false"
role="tab" aria-selected="@(!showGridView)" title="Listenansicht">
<span aria-hidden="true">☰</span> Liste
</button>
<button class="view-btn @(showGridView ? "active" : "")"
@onclick="() => showGridView = true"
role="tab" aria-selected="@showGridView" title="Regalansicht">
<span aria-hidden="true">▦</span> Regal
</button>
</div>
@if (HasActiveFilters)
{
<button class="btn btn-outline btn-sm clear-filters" @onclick="ResetFilters" title="Filter zurücksetzen">
✕ Filter löschen
</button>
}
</div>
</section>
@* 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 *@
@if (isLoading)
{
@@ -224,24 +205,22 @@ else
@if (selectedLocation.IsOccupied && selectedLocation.OccupiedTireStack != null)
{
var stack = selectedLocation.OccupiedTireStack;
<dl class="detail-grid">
<div class="detail-item">
<dt class="label">Ticket-Nr.</dt>
<dd class="value">@stack.TicketNumber</dd>
@StackDetailLists(stack)
@if (!string.IsNullOrEmpty(stack.Notes))
{
<div class="notes-section">
<span class="label">Notizen:</span>
<span class="notes-text">@stack.Notes</span>
</div>
<div class="detail-item">
<dt class="label">@(stack.IsInventory ? "Kategorie" : "Kunde")</dt>
<dd class="value">@(stack.IsInventory ? stack.CatalogItem?.Name ?? "Lagerbestand" : stack.Customer?.FullName ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Reifen</dt>
<dd class="value">@(stack.TireSize ?? "-") @(stack.WithRims ? "| mit Felgen" : "")</dd>
</div>
<div class="detail-item">
<dt class="label">Eingelagert</dt>
<dd class="value">@stack.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</dd>
</div>
</dl>
}
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
<button type="button" class="btn btn-sm btn-primary" @onclick="() => OpenEditModalFor(stack)">✏️ Bearbeiten</button>
@if (stack.CustomerId.HasValue)
{
<a href="@CheckinUrl(stack)" class="btn btn-sm btn-outline">+ Einlagern</a>
}
<a href="@CheckoutUrl(stack)" class="btn btn-sm btn-outline">@(stack.IsInventory ? "Entnehmen" : "Abholen")</a>
</nav>
}
else if (!selectedLocation.IsAvailable)
{
@@ -270,110 +249,171 @@ 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">
@foreach (var ts in filteredTireStacks)
{
<article class="tireset-card @(ts.IsInventory ? "inventory" : "")"
@onclick="() => ToggleDetails(ts.Id)"
<article class="tireset-card @(ts.IsInventory ? "inventory" : "") @(selectedStack?.Id == ts.Id ? "selected" : "")"
@onclick="() => SelectStack(ts)"
role="listitem"
aria-expanded="@(expandedId == ts.Id)">
<div class="tireset-main">
<div class="tireset-left">
<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>
}
aria-selected="@(selectedStack?.Id == ts.Id)">
<div class="card-row card-row-primary">
<div class="card-identity">
@if (ts.IsInventory)
{
<span class="customer-name inventory-name">
<span aria-hidden="true">📦</span> @(ts.CatalogItem?.Name ?? "Lagerbestand")
</span>
<span class="inventory-badge">Lager</span>
}
else
{
<span class="customer-name">@ts.Customer?.FullName</span>
@if (ts.Vehicle != null)
{
<span class="vehicle-badge">@ts.Vehicle.Make @ts.Vehicle.Model</span>
}
}
</div>
<div class="card-location">
@if (ts.StorageLocation != null)
{
<span class="location-indicator">
<span class="loc-area @(ts.StorageLocation.Area == AreaType.Werkstatt ? "werkstatt" : "lager")">@ts.StorageLocation.Area.GetDisplayName()</span>
<span class="loc-part">Regal @ts.StorageLocation.Row</span>
<span class="loc-part">@ts.StorageLocation.Position.GetShortName()</span>
<span class="loc-part">P@(ts.StorageLocation.SlotNumber)</span>
</span>
}
else
{
<span class="location-code">-</span>
}
</div>
</div>
@if (expandedId == ts.Id)
{
<div class="tireset-details">
<dl class="detail-grid">
<div class="detail-item">
<dt class="label">Ticket-Nr.</dt>
<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>
}
<div class="card-row card-row-secondary">
<span class="season-indicator @ts.Season.ToString().ToLower()"
aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</span>
<span class="tire-details">@ts.ItemCount Reifen @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.TireModel != null ? $"| {ts.TireModel}" : "") @(ts.WithRims ? "| mit Felgen" : "")</span>
</div>
</article>
}
</section>
}
@if (showEditModal && editStack != null)
{
<div class="modal-overlay" @onclick="CloseEditModal">
<div class="modal-dialog" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
<header class="modal-header">
<h2 id="edit-modal-title">Reifenstapel bearbeiten – @editStack.TicketNumber</h2>
<button type="button" class="modal-close" @onclick="CloseEditModal" aria-label="Schließen">✕</button>
</header>
<div class="modal-body">
<div class="form-grid">
<div class="form-group">
<label for="edit-size">Reifengröße</label>
<input id="edit-size" class="form-control" @bind="editStack.TireSize" placeholder="z.B. 225/45R17" />
</div>
<div class="form-group">
<label for="edit-brand">Marke</label>
<input id="edit-brand" class="form-control" @bind="editStack.TireBrand" placeholder="z.B. Michelin" />
</div>
<div class="form-group">
<label for="edit-model">Modell</label>
<input id="edit-model" class="form-control" @bind="editStack.TireModel" placeholder="z.B. Pilot Sport 4" />
</div>
<div class="form-group">
<label for="edit-count">Anzahl</label>
<input id="edit-count" type="number" min="1" max="8" class="form-control" @bind="editStack.ItemCount" />
</div>
<div class="form-group">
<label for="edit-season">Saison</label>
<select id="edit-season" class="form-control" @bind="editStack.Season">
<option value="@TireSeason.Sommer">☀️ Sommer</option>
<option value="@TireSeason.Winter">❄️ Winter</option>
<option value="@TireSeason.Allrad">🔄 Allrad</option>
</select>
</div>
<div class="form-group">
<label for="edit-condition">Zustand</label>
<select id="edit-condition" class="form-control" @bind="editStack.Condition">
<option value="@TireCondition.Excellent">Sehr gut</option>
<option value="@TireCondition.Good">Gut</option>
<option value="@TireCondition.Fair">Ausreichend</option>
<option value="@TireCondition.Poor">Mangelhaft</option>
<option value="@TireCondition.Critical">Kritisch</option>
</select>
</div>
<div class="form-group">
<label for="edit-rims">Felgen</label>
<select id="edit-rims" class="form-control" @bind="editStack.WithRims">
<option value="true">Mit Felgen</option>
<option value="false">Ohne Felgen</option>
</select>
</div>
<div class="form-group form-group-wide">
<label for="edit-notes">Notizen</label>
<textarea id="edit-notes" class="form-control" rows="3" @bind="editStack.Notes"></textarea>
</div>
</div>
@if (!string.IsNullOrEmpty(editError))
{
<p class="edit-error" role="alert">@editError</p>
}
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-outline" @onclick="CloseEditModal" disabled="@isSaving">Abbrechen</button>
<button type="button" class="btn btn-primary" @onclick="SaveEdit" disabled="@isSaving">
@(isSaving ? "Speichern..." : "Speichern")
</button>
</footer>
</div>
</div>
}
@* Expiring Pickups Warning (only for customer stacks) *@
@if (expiringPickups.Count > 0)
{
@@ -394,6 +434,136 @@ else
}
@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> filteredTireStacks = new();
private List<TireStack> expiringPickups = new();
@@ -404,7 +574,36 @@ else
private TireSeason? seasonFilter;
private bool? typeFilter; // null = all, false = customer, true = inventory
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 showGridView;
private List<StorageLocation> allLocations = new();
@@ -487,6 +686,19 @@ else
ApplyFilters();
}
private bool HasActiveFilters =>
typeFilter != null || seasonFilter != null ||
!string.IsNullOrEmpty(selectedCategoryId) || !string.IsNullOrWhiteSpace(searchTerm);
private void ResetFilters()
{
typeFilter = null;
seasonFilter = null;
selectedCategoryId = "";
searchTerm = "";
ApplyFilters();
}
private void ApplyFilters()
{
var query = allTireStacks.AsEnumerable();
@@ -532,9 +744,86 @@ else
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)
@@ -542,10 +831,14 @@ else
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;
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)
@@ -34,12 +34,12 @@
Page Header - Responsive
======================================== */
.page-header {
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
@media (min-width: 480px) {
.page-header {
margin-bottom: 1.5rem;
margin-bottom: 0.75rem;
}
}
@@ -85,193 +85,145 @@
}
/* ========================================
Statistics Grid - Responsive
Compact Toolbar (Stats + Filter combined)
======================================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 1rem;
}
@media (min-width: 480px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 1.5rem;
}
}
@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);
.toolbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding: 0.6rem 0.75rem;
background: rgba(28, 28, 30, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 12px;
padding: 12px;
text-align: center;
}
@media (min-width: 480px) {
.stat-card {
padding: 14px;
border-radius: 14px;
}
}
@media (min-width: 768px) {
.stat-card {
padding: 20px;
}
border: 0.5px solid rgba(255, 255, 255, 0.1);
}
@media (min-width: 1024px) {
.stat-card {
padding: 24px;
border-radius: 16px;
.toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
}
.stat-value {
display: block;
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 {
/* Stat chips - clickable, act as filters */
.stat-chips {
display: flex;
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;
}
@media (min-width: 480px) {
.filter-bar {
gap: 10px;
margin-bottom: 1.5rem;
}
.stat-chip {
display: inline-flex;
align-items: center;
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) {
.filter-bar {
gap: 14px;
}
.stat-chip.static {
cursor: default;
}
.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 {
flex: 1;
min-width: 0;
width: 100%;
padding: 12px 14px;
background: rgba(28, 28, 30, 0.8);
padding: 7px 12px;
background: rgba(0, 0, 0, 0.3);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
border-radius: 8px;
color: #ffffff;
font-family: 'Rajdhani', sans-serif;
font-size: 16px; /* Prevents iOS zoom */
min-height: 34px;
}
@media (min-width: 480px) {
.search-input {
min-width: 200px;
border-radius: 12px;
}
}
@media (min-width: 768px) {
.search-input {
max-width: 400px;
padding: 14px 18px;
font-size: 1rem;
font-size: 0.9rem;
}
}
@@ -288,18 +240,14 @@
display: flex;
gap: 6px;
flex-wrap: wrap;
}
@media (max-width: 479.98px) {
.filter-buttons {
width: 100%;
justify-content: flex-start;
}
border: none;
margin: 0;
padding: 0;
}
.filter-btn {
padding: 10px 12px;
background: rgba(28, 28, 30, 0.8);
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);
@@ -307,15 +255,7 @@
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
min-height: 44px;
}
@media (min-width: 480px) {
.filter-btn {
padding: 10px 14px;
border-radius: 10px;
font-size: 0.9rem;
}
min-height: 34px;
}
.filter-btn:hover {
@@ -877,41 +817,27 @@
}
/* ========================================
Category Filter - Responsive
Category Select (in Toolbar)
======================================== */
.category-filter {
margin-bottom: 0.75rem;
}
@media (min-width: 480px) {
.category-filter {
margin-bottom: 1rem;
}
}
.category-filter .form-control {
width: 100%;
padding: 10px 14px;
background: rgba(28, 28, 30, 0.8);
.category-select {
width: auto;
min-width: 140px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.3);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
border-radius: 8px;
color: #ffffff;
font-family: 'Rajdhani', sans-serif;
font-size: 16px; /* Prevents iOS zoom */
font-size: 0.8rem;
min-height: 34px;
}
@media (min-width: 480px) {
.category-filter .form-control {
max-width: 300px;
}
}
.category-filter .form-control:focus {
.category-select:focus {
outline: none;
border-color: rgba(220, 38, 38, 0.5);
}
.category-filter .form-control option {
.category-select option {
background: #1c1c1e;
color: #ffffff;
}
@@ -921,26 +847,26 @@
======================================== */
.view-toggle {
display: flex;
gap: 6px;
margin-bottom: 0.75rem;
background: rgba(28, 28, 30, 0.6);
padding: 5px;
border-radius: 10px;
gap: 4px;
background: rgba(0, 0, 0, 0.3);
padding: 3px;
border-radius: 8px;
border: 0.5px solid rgba(255, 255, 255, 0.1);
}
.view-btn {
flex: 1;
padding: 8px 10px;
padding: 5px 10px;
background: transparent;
border: none;
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-height: 40px;
min-height: 28px;
white-space: nowrap;
}
.view-btn:hover {
@@ -1194,4 +1120,416 @@
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) */