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
+5040 -678

No files matched your search

+1
View File
@@ -236,6 +236,7 @@
@* Blazor Framework - defer loading *@
<script src="_framework/blazor.web.js" autostart="true"></script>
<script src="js/app-interop.js?v=@CacheVersion" defer></script>
<script src="js/qr-scanner.js?v=@CacheVersion" defer></script>
</body>
</html>
@@ -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>
<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 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="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 class="toolbar-controls">
<div class="search-box">
<input type="text" class="form-control" placeholder="🔍 Code suchen (z.B. 2135)..."
@bind="searchTerm" @bind:event="oninput" @onkeyup="ApplyFilter" />
</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">
<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>
</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
@if (viewMode == "grid")
{
<button class="btn btn-outline btn-sm" @onclick="ToggleAllRows" title="Alle Reihen ein-/ausklappen">
@(allRowsCollapsed ? "▶ Alle aufklappen" : "▼ Alle zuklappen")
</button>
<button class="filter-btn available @(statusFilter == StatusAvailable ? "active" : "")"
@onclick="FilterByStatusAvailable">
Frei
}
@if (HasActiveFilters)
{
<button class="btn btn-outline btn-sm clear-filters" @onclick="ResetFilters" title="Filter zurücksetzen">
✕ Filter löschen
</button>
<button class="filter-btn occupied @(statusFilter == StatusOccupied ? "active" : "")"
@onclick="FilterByStatusOccupied">
Belegt
</button>
</div>
</div>
<div class="filter-group search-group">
<span class="filter-label">Suche</span>
<div class="search-box">
<input type="text" class="form-control" placeholder="Code suchen (z.B. 2135)..."
@bind="searchTerm" @bind:event="oninput" @onkeyup="ApplyFilter" />
</div>
}
</div>
</div>
@@ -456,7 +425,85 @@ else
{
var ts = selectedLocation.OccupiedTireStack;
<div class="stored-tires">
<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;
}
+544 -17
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,8 +717,80 @@ else
<h2>Lagerplatz auswählen</h2>
<div class="location-selector">
@* Area Filter *@
@if (selectedLocation != null)
{
<div class="checkin-summary">
<h3 class="summary-title">📋 Zusammenfassung</h3>
<div class="summary-grid">
@if (isInventoryMode)
{
<div class="summary-item">
<span class="summary-label">Kategorie</span>
<span class="summary-value">📦 @selectedCategory?.Name</span>
</div>
}
else
{
<div class="summary-item">
<span class="summary-label">Kunde</span>
<span class="summary-value">👤 @selectedCustomer?.FullName</span>
</div>
@if (selectedVehicle != null)
{
<div class="summary-item">
<span class="summary-label">Fahrzeug</span>
<span class="summary-value">🚗 @selectedVehicle.DisplayName</span>
</div>
}
}
<div class="summary-item">
<span class="summary-label">@(isFelgenCategory ? "Felgen" : "Reifen")</span>
<span class="summary-value">
@(isInventoryMode && useCustomItemCount ? tireStack.ItemCount : TireStack.TiresPerStack) Stück
@if (!isFelgenCategory)
{
<text> @(tireStack.Season == TireSeason.Sommer ? "☀️" : tireStack.Season == TireSeason.Winter ? "❄️" : "🔄") @tireStack.Season</text>
}
</span>
</div>
@if (!string.IsNullOrEmpty(tireStack.TireSize))
{
<div class="summary-item">
<span class="summary-label">Größe</span>
<span class="summary-value">@tireStack.TireSize @(tireStack.WithRims && !isFelgenCategory ? "| mit Felgen" : "")</span>
</div>
}
@if (!string.IsNullOrEmpty(tireStack.TireBrand) || !string.IsNullOrEmpty(tireStack.TireModel))
{
<div class="summary-item">
<span class="summary-label">Marke</span>
<span class="summary-value">@tireStack.TireBrand @tireStack.TireModel</span>
</div>
}
<div class="summary-item highlight">
<span class="summary-label">Lagerplatz</span>
<span class="summary-value location">
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" ShowIcon="true" Size="LocationDisplaySize.Small" />
</span>
</div>
</div>
<button class="btn-clear summary-clear" @onclick="ClearSelectedLocation" title="Lagerplatz-Auswahl aufheben">✕</button>
</div>
}
@if (locationSelectorCollapsed && selectedLocation != null)
{
<button type="button" class="location-collapsed-bar" @onclick="ExpandLocationSelector" title="Lagerplatz ändern">
<span class="collapsed-icon">📍</span>
<span class="collapsed-code">@selectedLocation.Code</span>
<span class="collapsed-hint">Lagerplatz ändern ▾</span>
</button>
}
else
{
<div class="location-selector compact">
@* Area & Row Filter combined *@
<div class="location-filter-row">
<div class="area-filter">
<button class="filter-btn @(selectedAreaFilter == null ? "active" : "")"
@onclick="() => FilterLocationsByArea(null)">
@@ -693,7 +806,6 @@ else
</button>
</div>
@* Row Filter *@
<div class="row-filter">
<select class="form-control" @bind="selectedRowFilter" @bind:after="ApplyLocationFilter">
<option value="">Alle Reihen</option>
@@ -703,6 +815,7 @@ else
}
</select>
</div>
</div>
@* Location Grid *@
@if (filteredLocations.Count == 0)
@@ -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;
}
@media (min-width: 480px) {
.area-section {
margin-bottom: 1.5rem;
}
}
.area-title {
font-family: 'Rajdhani', sans-serif;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
margin: 0 0 0.5rem 0;
}
@media (min-width: 480px) {
.area-title {
font-size: 1.1rem;
margin: 0 0 0.75rem 0;
}
}
.row-section {
margin-bottom: 0.75rem;
}
@media (min-width: 480px) {
.row-section {
.area-section {
margin-bottom: 1rem;
}
}
.area-title {
font-family: 'Rajdhani', sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
margin: 0 0 0.4rem 0;
}
@media (min-width: 480px) {
.area-title {
font-size: 0.95rem;
margin: 0 0 0.5rem 0;
}
}
.row-section {
margin-bottom: 0.5rem;
}
@media (min-width: 480px) {
.row-section {
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;
}
}
+464 -171
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>
</section>
@* Type Filter (Customer vs Inventory) *@
<nav class="type-filter" role="tablist" aria-label="Filter nach Typ">
<button class="type-btn @(typeFilter == null ? "active" : "")"
@* 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)">
Alle
role="tab" aria-selected="@(typeFilter == null)"
title="Alle Stapel anzeigen">
<span class="chip-value">@stats.TotalStacksStored</span> Stapel (@stats.TotalTiresStored Reifen)
</button>
<button class="type-btn @(typeFilter == false ? "active" : "")"
@onclick="() => SetTypeFilter(false)"
role="tab"
aria-selected="@(typeFilter == false)">
<span aria-hidden="true">👤</span> Kundenstapel
<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="type-btn @(typeFilter == true ? "active" : "")"
@onclick="() => SetTypeFilter(true)"
role="tab"
aria-selected="@(typeFilter == true)">
<span aria-hidden="true">📦</span> Lagerbestand
<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>
</nav>
<span class="stat-chip static" title="Freie Lagerplätze">
<span class="chip-value free">@stats.AvailableLocations</span> Frei
</span>
</div>
@* 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">
<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, Kategorie, Ticket-Nr.)..."
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 == null ? "active" : "")"
@onclick="() => SetSeasonFilter(null)"
aria-pressed="@(seasonFilter == null)">
Alle
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Sommer)"
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Sommer ? null : TireSeason.Sommer)"
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
aria-label="Sommerreifen">
aria-label="Sommerreifen" title="Sommerreifen">
<span aria-hidden="true">☀️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Winter)"
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Winter ? null : TireSeason.Winter)"
aria-pressed="@(seasonFilter == TireSeason.Winter)"
aria-label="Winterreifen">
aria-label="Winterreifen" title="Winterreifen">
<span aria-hidden="true">❄️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Allrad)"
@onclick="() => SetSeasonFilter(seasonFilter == TireSeason.Allrad ? null : TireSeason.Allrad)"
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
aria-label="Ganzjahresreifen">
aria-label="Ganzjahresreifen" title="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>
<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>
</section>
}
<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>
@* 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">
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>
}
<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)
@if (ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
<span class="vehicle-badge">@ts.Vehicle.Make @ts.Vehicle.Model</span>
}
@if (ts.IsInventory)
{
<span class="inventory-badge">Lager</span>
}
</div>
</div>
@if (expandedId == ts.Id)
<div class="card-location">
@if (ts.StorageLocation != null)
{
<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>
<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
{
<a href="/tire-checkout" class="btn btn-sm btn-primary">Entnehmen</a>
<span class="location-code">-</span>
}
</nav>
</div>
}
</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) */
@@ -0,0 +1,161 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
@if (IsOpen)
{
<div class="qr-overlay" @onclick="Cancel">
<div class="qr-dialog" role="dialog" aria-modal="true" aria-labelledby="qr-title" @onclick:stopPropagation="true">
<div class="qr-header">
<div>
<h3 id="qr-title">@Title</h3>
@if (!string.IsNullOrEmpty(Subtitle))
{
<p class="qr-subtitle">@Subtitle</p>
}
</div>
<button type="button" class="qr-close" @onclick="Cancel" aria-label="Schließen">✕</button>
</div>
<div class="qr-body">
<div id="@_elementId" class="qr-viewport"></div>
@if (isStarting)
{
<div class="qr-status"><span class="spinner"></span> Kamera wird gestartet…</div>
}
else if (!string.IsNullOrEmpty(cameraError))
{
<div class="qr-status error">📷 @cameraError</div>
}
else
{
<div class="qr-status">QR-Code des Reports vor die Kamera halten</div>
}
<div class="qr-manual">
<label for="qr-manual-input">Oder Link / Nummer einfügen</label>
<div class="qr-manual-row">
<input id="qr-manual-input" type="text" class="form-control" placeholder="https://files.cnlaunch.com/…?dataRecordSn=…"
@bind="manualText" @bind:event="oninput" @onkeydown="OnManualKeyDown" />
<button type="button" class="btn btn-primary" @onclick="SubmitManual" disabled="@string.IsNullOrWhiteSpace(manualText)">Übernehmen</button>
</div>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public string Title { get; set; } = "QR-Code scannen";
[Parameter] public string? Subtitle { get; set; }
[Parameter] public EventCallback<string> OnScanned { get; set; }
private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}";
private DotNetObjectReference<QrScannerModal>? _selfRef;
private bool _scannerActive;
private bool _wasOpen;
private bool isStarting;
private string? cameraError;
private string manualText = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (IsOpen && !_wasOpen)
{
_wasOpen = true;
await StartScanner();
}
else if (!IsOpen && _wasOpen)
{
_wasOpen = false;
await StopScanner();
}
}
private async Task StartScanner()
{
cameraError = null;
isStarting = true;
manualText = "";
StateHasChanged();
try
{
_selfRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("qrScanner.start", _elementId, _selfRef);
_scannerActive = true;
}
catch (JSException ex)
{
cameraError = ExtractMessage(ex.Message);
}
catch (Exception ex)
{
cameraError = ex.Message;
}
finally
{
isStarting = false;
StateHasChanged();
}
}
private static string ExtractMessage(string raw)
{
if (raw.Contains("NotAllowedError", StringComparison.OrdinalIgnoreCase) || raw.Contains("Permission", StringComparison.OrdinalIgnoreCase))
return "Kamerazugriff wurde verweigert. Bitte Berechtigung im Browser erlauben.";
if (raw.Contains("NotFoundError", StringComparison.OrdinalIgnoreCase) || raw.Contains("Keine Kamera", StringComparison.OrdinalIgnoreCase))
return "Keine Kamera gefunden.";
var idx = raw.IndexOf('\n');
return idx > 0 ? raw[..idx] : raw;
}
private async Task StopScanner()
{
if (!_scannerActive) return;
_scannerActive = false;
try { await JS.InvokeVoidAsync("qrScanner.stop"); } catch { }
}
[JSInvokable]
public async Task OnQrScanned(string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
await Close();
await OnScanned.InvokeAsync(text.Trim());
}
private async Task SubmitManual()
{
if (string.IsNullOrWhiteSpace(manualText)) return;
var text = manualText.Trim();
await Close();
await OnScanned.InvokeAsync(text);
}
private async Task OnManualKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") await SubmitManual();
}
private async Task Cancel() => await Close();
private async Task Close()
{
await StopScanner();
_wasOpen = false;
if (IsOpen)
{
IsOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
}
public async ValueTask DisposeAsync()
{
await StopScanner();
_selfRef?.Dispose();
}
}
@@ -0,0 +1,142 @@
.qr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1200;
}
.qr-dialog {
background: #1c1c1e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.qr-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
}
.qr-header h3 {
margin: 0;
font-family: 'Rajdhani', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: #ffffff;
}
.qr-subtitle {
margin: 0.2rem 0 0;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
}
.qr-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.qr-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.qr-body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.qr-viewport {
width: 100%;
min-height: 260px;
border-radius: 12px;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.1);
}
::deep .qr-viewport video {
width: 100% !important;
border-radius: 12px;
}
::deep .qr-viewport img[alt="Info icon"],
::deep .qr-viewport img[alt="Camera based scan"] {
display: none;
}
.qr-status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: 'Rajdhani', sans-serif;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.qr-status.error {
color: #f87171;
}
.qr-status .spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #dc2626;
border-radius: 50%;
animation: qr-spin 0.8s linear infinite;
}
@keyframes qr-spin {
to { transform: rotate(360deg); }
}
.qr-manual {
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
padding-top: 0.9rem;
}
.qr-manual label {
display: block;
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 0.4rem;
}
.qr-manual-row {
display: flex;
gap: 0.5rem;
}
.qr-manual-row .form-control {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,190 @@
@using Lagerbestand.Models
@using Lagerbestand.Services
@inject IStorageLocationService StorageLocationService
@if (IsOpen)
{
<div class="picker-overlay" @onclick="Cancel">
<div class="picker-dialog" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="picker-title">
<header class="picker-header">
<div>
<h2 id="picker-title">Lagerplatz wählen</h2>
@if (!string.IsNullOrEmpty(Subtitle))
{
<p class="picker-subtitle">@Subtitle</p>
}
</div>
<button type="button" class="picker-close" @onclick="Cancel" aria-label="Schließen">✕</button>
</header>
<div class="picker-body">
@if (isLoading)
{
<div class="picker-loading" role="status">Lade Lagerplätze...</div>
}
else
{
<div class="rack-view">
@foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
{
<div class="building-section" @key="area">
<h3 class="building-title">@area.GetBuildingLabel()</h3>
@for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
{
var currentRow = row;
<div class="rack-row" @key="@($"{area}-{currentRow}")">
<h4 class="row-title">Reihe @currentRow</h4>
<div class="rack-sides">
@foreach (var side in new[] { "Links", "Rechts" })
{
<div class="rack-side" @key="side">
<span class="side-label">@side</span>
@foreach (var pos in ShelfPositionExtensions.All.Where(p => p.IsLeft() == (side == "Links")).OrderByDescending(p => p.IsTop()))
{
<div class="rack-level" @key="pos">
<span class="level-label">@(pos.IsTop() ? "Oben" : "Unten")</span>
<div class="rack-cells">
@for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
var loc = GetLocation(area, currentRow, pos, slot);
var isHighlighted = loc != null && HighlightLocationId == loc.Id;
var isSelectable = loc != null && (isHighlighted || (loc.IsAvailable && !loc.IsOccupied));
var cellClass = loc == null ? "missing" : isHighlighted ? "freeing" : !loc.IsAvailable ? "blocked" : loc.IsOccupied ? "occupied" : "free";
<button type="button"
class="rack-cell @cellClass @(pendingLocation?.Id == loc?.Id ? "selected" : "")"
@key="slot"
disabled="@(!isSelectable)"
title="@GetTooltip(loc, area, currentRow, pos, slot, isHighlighted)"
@onclick="() => Select(loc)">
@slot
</button>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
</div>
}
</div>
<div class="rack-legend" aria-hidden="true">
<span class="legend-item"><span class="legend-swatch free"></span> Frei</span>
@if (HighlightLocationId.HasValue)
{
<span class="legend-item"><span class="legend-swatch freeing"></span> Wird frei</span>
}
<span class="legend-item"><span class="legend-swatch occupied"></span> Belegt</span>
<span class="legend-item"><span class="legend-swatch blocked"></span> Gesperrt</span>
</div>
}
</div>
<footer class="picker-footer">
<div class="picker-selection">
@if (pendingLocation != null)
{
<span class="picker-selection-label">Ausgewählt:</span>
<LocationDisplay Code="@pendingLocation.Code" DisplayMode="LocationDisplayMode.Full" Size="LocationDisplaySize.Small" />
}
else
{
<span class="picker-selection-hint">Bitte einen freien Platz anklicken</span>
}
</div>
<div class="picker-actions">
<button type="button" class="btn btn-outline" @onclick="Cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" @onclick="Confirm" disabled="@(pendingLocation == null)">Übernehmen</button>
</div>
</footer>
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
/// <summary>Currently selected location (pre-selected when opening).</summary>
[Parameter] public Guid? SelectedLocationId { get; set; }
/// <summary>Location that is about to be freed (e.g. during a swap) – shown as selectable.</summary>
[Parameter] public Guid? HighlightLocationId { get; set; }
[Parameter] public string? Subtitle { get; set; }
[Parameter] public EventCallback<StorageLocation> OnSelected { get; set; }
private bool isLoading;
private bool wasOpen;
private List<StorageLocation> allLocations = new();
private Dictionary<(AreaType, int, ShelfPosition, int), StorageLocation> lookup = new();
private StorageLocation? pendingLocation;
protected override async Task OnParametersSetAsync()
{
if (IsOpen && !wasOpen)
{
wasOpen = true;
await LoadAsync();
}
else if (!IsOpen)
{
wasOpen = false;
}
}
private async Task LoadAsync()
{
isLoading = true;
try
{
allLocations = await StorageLocationService.GetAllAsync();
lookup = allLocations.ToDictionary(l => (l.Area, l.Row, l.Position, l.SlotNumber));
pendingLocation = SelectedLocationId.HasValue
? allLocations.FirstOrDefault(l => l.Id == SelectedLocationId.Value)
: null;
}
finally
{
isLoading = false;
}
}
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
=> lookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
private void Select(StorageLocation? loc)
{
if (loc == null) return;
pendingLocation = loc;
}
private async Task Confirm()
{
if (pendingLocation == null) return;
await OnSelected.InvokeAsync(pendingLocation);
await Close();
}
private Task Cancel() => Close();
private async Task Close()
{
IsOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
private string GetTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot, bool isHighlighted)
{
var baseText = $"{area.GetDisplayName()}, Reihe {row}, {pos.GetDisplayName()}, Platz {slot}";
if (loc == null) return $"{baseText} – nicht angelegt";
if (isHighlighted) return $"{baseText} – wird frei";
if (!loc.IsAvailable) return $"{baseText} – gesperrt";
if (loc.IsOccupied) return $"{baseText} – belegt";
return $"{baseText} – frei";
}
}
@@ -0,0 +1,309 @@
/* ========================================
Overlay & Dialog
======================================== */
.picker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1100;
}
.picker-dialog {
background: #1c1c1e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
width: 100%;
max-width: 1100px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
}
.picker-header h2 {
margin: 0;
font-family: 'Rajdhani', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: #ffffff;
}
.picker-subtitle {
margin: 0.2rem 0 0;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
}
.picker-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.picker-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.picker-body {
padding: 1rem 1.25rem;
overflow-y: auto;
flex: 1;
}
.picker-loading {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Rajdhani', sans-serif;
}
.picker-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.85rem 1.25rem;
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
}
.picker-selection {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'Rajdhani', sans-serif;
}
.picker-selection-label {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.picker-selection-hint {
color: rgba(255, 255, 255, 0.45);
}
.picker-actions {
display: flex;
gap: 0.5rem;
}
/* ========================================
Rack Grid (mirrors TireStorage rack view)
======================================== */
.rack-view {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
@media (min-width: 1024px) {
.rack-view {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.building-section {
background: rgba(28, 28, 30, 0.8);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
width: 100%;
}
.building-title {
font-family: 'Rajdhani', sans-serif;
font-size: 1.05rem;
font-weight: 600;
color: #ffffff;
margin: 0 0 0.75rem 0;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.rack-row {
margin-bottom: 0.75rem;
}
.row-title {
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 0.3rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rack-sides {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 767.98px) {
.rack-sides {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
.rack-side {
min-width: 0;
}
.side-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 0.2rem;
}
.rack-level {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.2rem 0;
}
.level-label {
width: 2.6rem;
flex-shrink: 0;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.4);
}
.rack-cells {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 4px;
flex: 1;
min-width: 0;
}
.rack-cell {
width: 100%;
min-height: 2rem;
border-radius: 6px;
border: 1px solid transparent;
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
padding: 0;
}
.rack-cell:not(:disabled):hover {
transform: scale(1.1);
}
.rack-cell.free {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #4ade80;
}
.rack-cell.freeing {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(96, 165, 250, 0.5);
color: #93c5fd;
}
.rack-cell.occupied {
background: rgba(220, 38, 38, 0.2);
border-color: rgba(220, 38, 38, 0.45);
color: #f87171;
cursor: not-allowed;
}
.rack-cell.blocked {
background: rgba(148, 163, 184, 0.15);
border-color: rgba(148, 163, 184, 0.3);
color: rgba(255, 255, 255, 0.35);
cursor: not-allowed;
}
.rack-cell.missing {
background: transparent;
border: 1px dashed rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.2);
cursor: default;
}
.rack-cell.selected {
outline: 2px solid #22c55e;
outline-offset: 1px;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
}
/* ========================================
Legend
======================================== */
.rack-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.75rem;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Rajdhani', sans-serif;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.legend-swatch {
width: 0.9rem;
height: 0.9rem;
border-radius: 4px;
display: inline-block;
}
.legend-swatch.free {
background: rgba(34, 197, 94, 0.4);
}
.legend-swatch.freeing {
background: rgba(96, 165, 250, 0.5);
}
.legend-swatch.occupied {
background: rgba(220, 38, 38, 0.45);
}
.legend-swatch.blocked {
background: rgba(148, 163, 184, 0.35);
}
+27 -1
View File
@@ -17,6 +17,7 @@ public class AppDbContext : DbContext
public DbSet<StorageLocation> StorageLocations => Set<StorageLocation>();
public DbSet<TireStack> TireStacks => Set<TireStack>();
public DbSet<TireItem> TireItems => Set<TireItem>();
public DbSet<TireInspection> TireInspections => Set<TireInspection>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
// Kanban entities
@@ -84,7 +85,7 @@ public class AppDbContext : DbContext
{
entity.ToTable("Customers");
entity.HasKey(e => e.Id);
entity.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
entity.Property(e => e.FirstName).HasMaxLength(100);
entity.Property(e => e.LastName).IsRequired().HasMaxLength(100);
entity.Property(e => e.Company).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(20);
@@ -230,6 +231,31 @@ public class AppDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// TireInspection configuration
modelBuilder.Entity<TireInspection>(entity =>
{
entity.ToTable("TireInspections");
entity.HasKey(e => e.Id);
entity.Property(e => e.Source).HasMaxLength(50);
entity.Property(e => e.RecordSn).HasMaxLength(100);
entity.Property(e => e.ReportUrl).HasMaxLength(500);
entity.Property(e => e.LicensePlate).HasMaxLength(20);
entity.Property(e => e.Recommendation).HasMaxLength(1000);
entity.Property(e => e.RawJson).HasColumnType("LONGTEXT");
entity.Property(e => e.RatingFL).HasConversion<int>();
entity.Property(e => e.RatingFR).HasConversion<int>();
entity.Property(e => e.RatingRL).HasConversion<int>();
entity.Property(e => e.RatingRR).HasConversion<int>();
entity.HasIndex(e => e.TireStackId);
entity.HasIndex(e => e.RecordSn);
entity.HasOne(e => e.TireStack)
.WithMany(t => t.Inspections)
.HasForeignKey(e => e.TireStackId)
.OnDelete(DeleteBehavior.Cascade);
});
// SiteSettings configuration
modelBuilder.Entity<SiteSettings>(entity =>
{
+3
View File
@@ -4,6 +4,9 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.1.0</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
</PropertyGroup>
<ItemGroup>
+116
View File
@@ -0,0 +1,116 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Condition rating of a single tire as reported by the measurement device.
/// </summary>
public enum TireMeasureRating
{
Unknown = 0,
Ok = 1,
Warning = 2,
Critical = 3
}
/// <summary>
/// A tread depth / condition measurement for a tire stack, typically imported
/// from a Launch TTS01 report (QR code) during checkin.
/// </summary>
public class TireInspection
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TireStackId { get; set; }
[ForeignKey(nameof(TireStackId))]
[System.Text.Json.Serialization.JsonIgnore]
public TireStack? TireStack { get; set; }
[MaxLength(50)]
public string Source { get; set; } = "Launch TTS01";
[MaxLength(100)]
public string? RecordSn { get; set; }
[MaxLength(500)]
public string? ReportUrl { get; set; }
public DateTime? MeasuredAt { get; set; }
[MaxLength(20)]
public string? LicensePlate { get; set; }
public int? Mileage { get; set; }
[Column(TypeName = "decimal(5,2)")]
public decimal? TreadDepthFL { get; set; }
[Column(TypeName = "decimal(5,2)")]
public decimal? TreadDepthFR { get; set; }
[Column(TypeName = "decimal(5,2)")]
public decimal? TreadDepthRL { get; set; }
[Column(TypeName = "decimal(5,2)")]
public decimal? TreadDepthRR { get; set; }
public TireMeasureRating RatingFL { get; set; }
public TireMeasureRating RatingFR { get; set; }
public TireMeasureRating RatingRL { get; set; }
public TireMeasureRating RatingRR { get; set; }
[MaxLength(1000)]
public string? Recommendation { get; set; }
public string? RawJson { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public Guid? CreatedByUserId { get; set; }
[NotMapped]
public decimal? MinTreadDepth
{
get
{
var values = new[] { TreadDepthFL, TreadDepthFR, TreadDepthRL, TreadDepthRR }.Where(v => v.HasValue).Select(v => v!.Value).ToList();
return values.Count == 0 ? null : values.Min();
}
}
[NotMapped]
public TireMeasureRating WorstRating
{
get
{
var ratings = new[] { RatingFL, RatingFR, RatingRL, RatingRR };
return ratings.Max();
}
}
[NotMapped]
public bool HasTreadData => TreadDepthFL.HasValue || TreadDepthFR.HasValue || TreadDepthRL.HasValue || TreadDepthRR.HasValue;
/// <summary>Legal minimum tread depth in Germany (mm).</summary>
public const decimal DefaultCriticalThreshold = 1.6m;
/// <summary>Common workshop recommendation threshold for replacement (mm).</summary>
public const decimal DefaultWarningThreshold = 3.0m;
/// <summary>
/// Derives a rating from a tread depth in mm. Values below <paramref name="critical"/>
/// are critical, values up to <paramref name="warning"/> are a warning.
/// </summary>
public static TireMeasureRating RateDepth(decimal? mm, decimal? critical = null, decimal? warning = null)
{
if (!mm.HasValue) return TireMeasureRating.Unknown;
var crit = critical ?? DefaultCriticalThreshold;
var warn = Math.Max(warning ?? DefaultWarningThreshold, crit);
if (mm.Value < crit) return TireMeasureRating.Critical;
if (mm.Value <= warn) return TireMeasureRating.Warning;
return TireMeasureRating.Ok;
}
}
+13 -1
View File
@@ -31,7 +31,13 @@ public enum TireStackStatus
/// <summary>
/// Tires are in inventory (shop stock, not customer-owned)
/// </summary>
Inventory = 4
Inventory = 4,
/// <summary>
/// Tires are currently mounted on the customer's vehicle (after a seasonal swap).
/// They can be re-stored later when swapping back.
/// </summary>
OnVehicle = 5
}
/// <summary>
@@ -96,6 +102,11 @@ public class TireStack
[ForeignKey(nameof(CatalogItemId))]
public CatalogItem? CatalogItem { get; set; }
/// <summary>
/// Tread depth / condition measurements (e.g. imported from QR report)
/// </summary>
public ICollection<TireInspection> Inspections { get; set; } = new List<TireInspection>();
/// <summary>
/// Current status of the tire stack
/// </summary>
@@ -234,6 +245,7 @@ public class TireStack
TireStackStatus.Disposed => "Entsorgt",
TireStackStatus.Reserved => "Reserviert",
TireStackStatus.Inventory => "Lagerbestand",
TireStackStatus.OnVehicle => "Montiert",
_ => "Unbekannt"
};
+4
View File
@@ -129,6 +129,10 @@ builder.Services.AddScoped<ILocationCodeService, LocationCodeService>();
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
builder.Services.AddScoped<ITireStackService, TireStackService>();
// Launch TTS01 tire report (QR-Code) lookup
builder.Services.AddHttpClient("LaunchReport", client => client.Timeout = TimeSpan.FromSeconds(15));
builder.Services.AddScoped<ITireReportService, TireReportService>();
// Register Kanban service
builder.Services.AddScoped<IKanbanService, KanbanService>();
+51 -3
View File
@@ -124,9 +124,6 @@ public class DatabaseInitializer : IDatabaseInitializer
await using var connection = new MySqlConnection(connectionString);
// Set connection timeout
connection.ConnectionTimeout.ToString();
await connection.OpenAsync(cancellationToken);
// Core tables
@@ -141,6 +138,7 @@ public class DatabaseInitializer : IDatabaseInitializer
await ExecuteSqlSafeAsync(connection, CreateStorageLocationsTableSql, "StorageLocations", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireStacksTableSql, "TireStacks", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireItemsTableSql, "TireItems", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireInspectionsTableSql, "TireInspections", cancellationToken);
// Kanban tables
await ExecuteSqlSafeAsync(connection, CreateKanbanColumnsTableSql, "KanbanColumns", cancellationToken);
@@ -205,6 +203,27 @@ public class DatabaseInitializer : IDatabaseInitializer
"ALTER TABLE `Customers` MODIFY COLUMN `FirstName` VARCHAR(100) NULL;",
"MakeCustomerFirstNameNullable", cancellationToken);
// Data repair: backfill NULLs in columns mapped as non-nullable strings
// (prevents InvalidCastException: DBNull -> String when materializing entities)
await ExecuteSqlSafeAsync(connection,
"UPDATE `Customers` SET `LastName` = '-' WHERE `LastName` IS NULL;",
"BackfillCustomerLastName", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"UPDATE `Customers` SET `CustomerNumber` = CONCAT('AUTO-', SUBSTRING(REPLACE(`Id`, '-', ''), 1, 12)) WHERE `CustomerNumber` IS NULL;",
"BackfillCustomerNumber", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Customers` MODIFY COLUMN `LastName` VARCHAR(100) NOT NULL, MODIFY COLUMN `CustomerNumber` VARCHAR(20) NOT NULL;",
"EnforceCustomerNotNullColumns", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"UPDATE `Vehicles` SET `Make` = 'Unbekannt' WHERE `Make` IS NULL;",
"BackfillVehicleMake", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"UPDATE `Vehicles` SET `TireSize` = '' WHERE `TireSize` IS NULL;",
"BackfillVehicleTireSize", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Vehicles` MODIFY COLUMN `Make` VARCHAR(100) NOT NULL, MODIFY COLUMN `TireSize` VARCHAR(50) NOT NULL DEFAULT '';",
"EnforceVehicleNotNullColumns", cancellationToken);
// Migration: Add 2FA columns to Users table
await MigrateAdd2FaColumnsToUsersAsync(connection, cancellationToken);
@@ -1020,6 +1039,35 @@ public class DatabaseInitializer : IDatabaseInitializer
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateTireInspectionsTableSql = """
CREATE TABLE IF NOT EXISTS `TireInspections` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Source` VARCHAR(50) NOT NULL DEFAULT 'Launch TTS01',
`RecordSn` VARCHAR(100) NULL,
`ReportUrl` VARCHAR(500) NULL,
`MeasuredAt` DATETIME(6) NULL,
`LicensePlate` VARCHAR(20) NULL,
`Mileage` INT NULL,
`TreadDepthFL` DECIMAL(5,2) NULL,
`TreadDepthFR` DECIMAL(5,2) NULL,
`TreadDepthRL` DECIMAL(5,2) NULL,
`TreadDepthRR` DECIMAL(5,2) NULL,
`RatingFL` INT NOT NULL DEFAULT 0,
`RatingFR` INT NOT NULL DEFAULT 0,
`RatingRL` INT NOT NULL DEFAULT 0,
`RatingRR` INT NOT NULL DEFAULT 0,
`Recommendation` VARCHAR(1000) NULL,
`RawJson` LONGTEXT NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`CreatedByUserId` CHAR(36) CHARACTER SET ascii NULL,
PRIMARY KEY (`Id`),
INDEX `IX_TireInspections_TireStackId` (`TireStackId`),
INDEX `IX_TireInspections_RecordSn` (`RecordSn`),
CONSTRAINT `FK_TireInspections_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
#region Kanban SQL Statements
private const string CreateKanbanColumnsTableSql = """
@@ -290,24 +290,25 @@ public class StorageLocationService : IStorageLocationService
var toLocation = await GetByCodeAsync(toCode)
?? throw new LocationNotFoundException(toCode);
if (!fromLocation.IsOccupied)
if (!fromLocation.IsOccupied || fromLocation.TireStackId == null)
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
if (toLocation.IsOccupied)
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
throw toLocation.TireStackId.HasValue
? new LocationOccupiedException(toCode, toLocation.TireStackId.Value)
: new LocationOccupiedException(toCode);
if (!toLocation.IsAvailable)
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
var tireStackId = fromLocation.TireStackId!.Value;
var tireStackId = fromLocation.TireStackId.Value;
// Update the tire stack's storage location reference
var tireStack = await _context.TireStacks.FindAsync(tireStackId);
if (tireStack != null)
{
var tireStack = await _context.TireStacks.FindAsync(tireStackId)
?? throw new InvalidOperationException($"Reifenstapel auf Lagerplatz {fromCode} wurde nicht gefunden.");
tireStack.StorageLocationId = toLocation.Id;
tireStack.UpdatedAt = DateTime.UtcNow;
}
// Clear source and assign to destination
fromLocation.ClearLocation();
+343
View File
@@ -0,0 +1,343 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Lagerbestand.Models;
namespace Lagerbestand.Services;
/// <summary>
/// Parsed data of a tire measurement report (Launch TTS01 tread depth scanner).
/// </summary>
public class TireReportData
{
public string RecordSn { get; set; } = string.Empty;
public string ReportUrl { get; set; } = string.Empty;
public DateTime? MeasuredAt { get; set; }
public string? LicensePlate { get; set; }
public int? Mileage { get; set; }
public decimal? TreadDepthFL { get; set; }
public decimal? TreadDepthFR { get; set; }
public decimal? TreadDepthRL { get; set; }
public decimal? TreadDepthRR { get; set; }
public TireMeasureRating RatingFL { get; set; }
public TireMeasureRating RatingFR { get; set; }
public TireMeasureRating RatingRL { get; set; }
public TireMeasureRating RatingRR { get; set; }
public decimal? WarningThreshold { get; set; }
public decimal? CriticalThreshold { get; set; }
public decimal? BrakingDistanceDelta { get; set; }
public string? Recommendation { get; set; }
public string? DeviceSerial { get; set; }
public string? RawJson { get; set; }
/// <summary>True if the report itself contained tread depth values.</summary>
public bool HasTreadData => TreadDepthFL.HasValue || TreadDepthFR.HasValue || TreadDepthRL.HasValue || TreadDepthRR.HasValue;
public TireInspection ToInspection(Guid tireStackId, Guid? userId) => new()
{
TireStackId = tireStackId,
Source = "Launch TTS01",
RecordSn = Truncate(RecordSn, 100),
ReportUrl = Truncate(ReportUrl, 500),
MeasuredAt = MeasuredAt,
LicensePlate = Truncate(LicensePlate?.Trim().ToUpperInvariant(), 20),
Mileage = Mileage is < 0 ? null : Mileage,
TreadDepthFL = TreadDepthFL,
TreadDepthFR = TreadDepthFR,
TreadDepthRL = TreadDepthRL,
TreadDepthRR = TreadDepthRR,
RatingFL = RatingFL,
RatingFR = RatingFR,
RatingRL = RatingRL,
RatingRR = RatingRR,
Recommendation = Truncate(Recommendation, 1000),
RawJson = RawJson,
CreatedByUserId = userId
};
/// <summary>
/// Re-evaluates the per-wheel ratings from the current tread depths using the
/// thresholds delivered by the device (falls back to legal defaults).
/// </summary>
public void RecomputeRatings()
{
RatingFL = TireInspection.RateDepth(TreadDepthFL, CriticalThreshold, WarningThreshold);
RatingFR = TireInspection.RateDepth(TreadDepthFR, CriticalThreshold, WarningThreshold);
RatingRL = TireInspection.RateDepth(TreadDepthRL, CriticalThreshold, WarningThreshold);
RatingRR = TireInspection.RateDepth(TreadDepthRR, CriticalThreshold, WarningThreshold);
}
private static string? Truncate(string? s, int max)
{
if (string.IsNullOrEmpty(s)) return s;
return s.Length <= max ? s : s[..max];
}
}
public interface ITireReportService
{
/// <summary>
/// Tries to extract the report record number from a scanned QR text
/// (full share URL or bare record number).
/// </summary>
bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl);
/// <summary>
/// Loads and parses the report. Returns a data object with at least
/// RecordSn/ReportUrl; HasTreadData indicates whether values could be read.
/// Throws <see cref="TireReportException"/> with a user-friendly message on failure.
/// </summary>
Task<TireReportData> FetchAsync(string recordSn, CancellationToken cancellationToken = default);
}
public class TireReportException : Exception
{
public TireReportException(string message, Exception? inner = null) : base(message, inner) { }
}
public class TireReportService : ITireReportService
{
private const string DefaultShareUrl = "https://files.cnlaunch.com/assets/TTS01DetectReportShare.html?dataRecordSn=";
private const string DefaultApiUrl = "https://files.cnlaunch.com/api/ttm/getTtmReportData";
private static readonly Regex RecordSnRegex = new(@"dataRecordSn=([A-Za-z0-9_-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex BareSnRegex = new(@"^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled);
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<TireReportService> _logger;
private readonly string _apiUrl;
private readonly string _shareUrl;
public TireReportService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<TireReportService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_apiUrl = configuration["LaunchReport:ApiUrl"] ?? DefaultApiUrl;
_shareUrl = configuration["LaunchReport:ShareUrl"] ?? DefaultShareUrl;
}
public bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl)
{
recordSn = string.Empty;
reportUrl = string.Empty;
if (string.IsNullOrWhiteSpace(qrText)) return false;
var text = qrText.Trim();
var m = RecordSnRegex.Match(text);
if (m.Success)
{
recordSn = m.Groups[1].Value;
reportUrl = text.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? text : _shareUrl + recordSn;
return true;
}
if (BareSnRegex.IsMatch(text))
{
recordSn = text;
reportUrl = _shareUrl + recordSn;
return true;
}
return false;
}
public async Task<TireReportData> FetchAsync(string recordSn, CancellationToken cancellationToken = default)
{
var result = new TireReportData
{
RecordSn = recordSn,
ReportUrl = _shareUrl + recordSn
};
var client = _httpClientFactory.CreateClient("LaunchReport");
string json;
try
{
using var response = await client.PostAsJsonAsync(_apiUrl, new { dataRecordSn = recordSn }, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Launch report {Sn}: HTTP {Status}", recordSn, (int)response.StatusCode);
throw new TireReportException($"Report-Server antwortete mit HTTP {(int)response.StatusCode}.");
}
json = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (TireReportException) { throw; }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TireReportException("Zeitüberschreitung beim Laden des Reports.");
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Launch report {Sn}: Netzwerkfehler", recordSn);
throw new TireReportException("Report-Server nicht erreichbar. Bitte Internetverbindung prüfen.", ex);
}
result.RawJson = json;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("code", out var codeEl) && codeEl.ValueKind == JsonValueKind.Number && codeEl.GetInt32() != 200)
{
_logger.LogWarning("Launch report {Sn} returned code {Code}", recordSn, codeEl.GetInt32());
return result;
}
JsonElement report;
if (root.TryGetProperty("data", out var dataEl) && dataEl.ValueKind == JsonValueKind.Object
&& dataEl.TryGetProperty("reportInfoData", out var rid))
{
report = rid;
}
else if (root.TryGetProperty("detectData", out var dd))
{
report = dd;
}
else
{
return result;
}
Parse(report, result);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Launch report {Sn}: JSON konnte nicht gelesen werden", recordSn);
}
return result;
}
private static void Parse(JsonElement report, TireReportData result)
{
if (report.TryGetProperty("t", out var t))
{
long seconds = t.ValueKind == JsonValueKind.Number ? t.GetInt64()
: long.TryParse(t.GetString(), out var ls) ? ls : 0;
if (seconds > 100000)
result.MeasuredAt = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
}
if (report.TryGetProperty("sn", out var sn) && sn.ValueKind == JsonValueKind.String)
result.DeviceSerial = sn.GetString();
if (report.TryGetProperty("i", out var info) && info.ValueKind == JsonValueKind.Object)
{
if (info.TryGetProperty("cn", out var cn) && cn.ValueKind == JsonValueKind.String)
result.LicensePlate = NullIfEmpty(cn.GetString());
if (info.TryGetProperty("dt", out var dt))
{
if (dt.ValueKind == JsonValueKind.Number && dt.TryGetInt32(out var km)) result.Mileage = km;
else if (dt.ValueKind == JsonValueKind.String && int.TryParse(dt.GetString(), out var km2)) result.Mileage = km2;
}
}
if (!report.TryGetProperty("dr", out var dr) || dr.ValueKind != JsonValueKind.Object)
return;
// Thresholds "critical-warning", e.g. "1.6-2.5"
if (dr.TryGetProperty("ts", out var ts) && ts.ValueKind == JsonValueKind.String)
{
var parts = ts.GetString()!.Split('-');
if (parts.Length == 2)
{
result.CriticalThreshold = ParseDecimal(parts[0]);
result.WarningThreshold = ParseDecimal(parts[1]);
}
}
if (dr.TryGetProperty("d", out var d))
result.BrakingDistanceDelta = d.ValueKind == JsonValueKind.Number ? d.GetDecimal() : ParseDecimal(d.GetString());
// Minimum values per wheel: l1 = front left, r1 = front right, ll1 = rear left, rr1 = rear right
if (dr.TryGetProperty("rl", out var rl) && rl.ValueKind == JsonValueKind.Object)
{
result.TreadDepthFL = GetDecimal(rl, "l1");
result.TreadDepthFR = GetDecimal(rl, "r1");
result.TreadDepthRL = GetDecimal(rl, "ll1");
result.TreadDepthRR = GetDecimal(rl, "rr1");
}
// Fallback: compute minimum from raw readings (z = axle, p = side, gd = readings)
if (!result.HasTreadData && dr.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Array)
{
foreach (var tire in r.EnumerateArray())
{
var z = tire.TryGetProperty("z", out var ze) ? ze.GetInt32() : 0;
var p = tire.TryGetProperty("p", out var pe) ? pe.GetInt32() : 0;
decimal? min = null;
if (tire.TryGetProperty("gd", out var gd) && gd.ValueKind == JsonValueKind.Array)
{
foreach (var s in gd.EnumerateArray())
{
foreach (var v in (s.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var dv = ParseDecimal(v);
if (dv.HasValue && (!min.HasValue || dv < min)) min = dv;
}
}
}
switch (z, p)
{
case (1, 1): result.TreadDepthFL = min; break;
case (1, 2): result.TreadDepthFR = min; break;
case (2, 1): result.TreadDepthRL = min; break;
case (2, 2): result.TreadDepthRR = min; break;
}
}
}
result.RecomputeRatings();
result.Recommendation = BuildRecommendation(result);
}
private static string? BuildRecommendation(TireReportData d)
{
var worst = new[] { d.RatingFL, d.RatingFR, d.RatingRL, d.RatingRR }.Max();
var parts = new List<string>();
switch (worst)
{
case TireMeasureRating.Critical:
parts.Add("Profiltiefe unter Mindestwert – Reifen umgehend ersetzen.");
break;
case TireMeasureRating.Warning:
parts.Add("Profiltiefe im Grenzbereich – Reifenwechsel empfohlen.");
break;
case TireMeasureRating.Ok:
parts.Add("Profiltiefe in Ordnung.");
break;
}
var depths = new[] { d.TreadDepthFL, d.TreadDepthFR, d.TreadDepthRL, d.TreadDepthRR }.Where(x => x.HasValue).Select(x => x!.Value).ToList();
if (depths.Count >= 2 && depths.Max() - depths.Min() >= 2m)
parts.Add("Ungleichmäßige Abnutzung (Differenz ≥ 2 mm) – Achsvermessung/Rotation prüfen.");
if (d.BrakingDistanceDelta is > 0)
parts.Add($"Bremsweg ca. +{d.BrakingDistanceDelta.Value.ToString("0.#", CultureInfo.GetCultureInfo("de-DE"))} m gegenüber Neureifen.");
return parts.Count == 0 ? null : string.Join(" ", parts);
}
private static decimal? GetDecimal(JsonElement obj, string name)
{
if (!obj.TryGetProperty(name, out var el)) return null;
return el.ValueKind switch
{
JsonValueKind.Number => el.GetDecimal(),
JsonValueKind.String => ParseDecimal(el.GetString()),
_ => null
};
}
private static decimal? ParseDecimal(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
return decimal.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
}
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
}
+152 -2
View File
@@ -23,10 +23,25 @@ public interface ITireStackService
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
Task<SwapResult> SwapAsync(Guid outgoingTireStackId, TireStack incomingTireStack, Guid? targetLocationId = null, Guid? userId = null);
Task<bool> DeleteAsync(Guid id);
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
Task<StorageStatistics> GetStatisticsAsync();
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
Task<TireInspection> AddInspectionAsync(TireInspection inspection);
Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId);
Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId);
}
/// <summary>
/// Result of a seasonal tire swap (one stack out, another stack in)
/// </summary>
public class SwapResult
{
public required TireStack Outgoing { get; init; }
public required TireStack Incoming { get; init; }
public string? FreedLocationCode { get; init; }
public string? NewLocationCode { get; init; }
}
/// <summary>
@@ -303,13 +318,13 @@ public class TireStackService : ITireStackService
var tireStack = await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.StorageLocation)
.FirstOrDefaultAsync(ts => ts.Id == tireStackId);
.FirstOrDefaultAsync(ts => ts.Id == tireStackId && ts.IsActive);
if (tireStack == null)
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved)
throw new InvalidOperationException("Reifenstapel ist nicht eingelagert.");
throw new InvalidOperationException($"Reifenstapel {tireStack.TicketNumber} ist nicht eingelagert.");
// Clear the storage location (both sides)
if (tireStack.StorageLocation != null)
@@ -402,6 +417,111 @@ public class TireStackService : ITireStackService
}
}
public async Task<SwapResult> SwapAsync(Guid outgoingTireStackId, TireStack incomingTireStack, Guid? targetLocationId = null, Guid? userId = null)
{
var strategy = _context.Database.CreateExecutionStrategy();
try
{
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var outgoing = await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.FirstOrDefaultAsync(ts => ts.Id == outgoingTireStackId && ts.IsActive);
if (outgoing == null)
throw new InvalidOperationException("Herauszugebender Reifenstapel nicht gefunden.");
if (outgoing.Status != TireStackStatus.Stored && outgoing.Status != TireStackStatus.Reserved)
throw new InvalidOperationException("Herauszugebender Reifenstapel ist nicht eingelagert.");
if (outgoing.CustomerId == null)
throw new InvalidOperationException("Reifenwechsel ist nur für Kundenreifen möglich.");
var freedLocationId = outgoing.StorageLocationId;
var freedLocationCode = outgoing.StorageLocation?.Code;
// 1. Release outgoing stack: mark as mounted on vehicle, free the location
if (outgoing.StorageLocation != null)
outgoing.StorageLocation.ClearLocation();
outgoing.Status = TireStackStatus.OnVehicle;
outgoing.PickedUpAt = DateTime.UtcNow;
outgoing.PickedUpByUserId = userId;
outgoing.StorageLocationId = null;
outgoing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
// 2. Resolve incoming stack: existing (from history) or new
TireStack incoming;
var existing = await _context.TireStacks
.FirstOrDefaultAsync(ts => ts.Id == incomingTireStack.Id && ts.IsActive);
if (existing != null)
{
if (existing.Status != TireStackStatus.OnVehicle && existing.Status != TireStackStatus.PickedUp)
throw new InvalidOperationException($"Reifenstapel {existing.TicketNumber} kann nicht wieder eingelagert werden (Status: {existing.StatusText}).");
if (existing.CustomerId != outgoing.CustomerId)
throw new InvalidOperationException("Der einzulagernde Reifenstapel gehört einem anderen Kunden.");
existing.PickedUpAt = null;
existing.PickedUpByUserId = null;
existing.ExpectedPickupDate = incomingTireStack.ExpectedPickupDate;
if (!string.IsNullOrWhiteSpace(incomingTireStack.Notes))
existing.Notes = incomingTireStack.Notes;
existing.Condition = incomingTireStack.Condition;
if (incomingTireStack.VehicleId != null)
existing.VehicleId = incomingTireStack.VehicleId;
incoming = existing;
}
else
{
incomingTireStack.CustomerId = outgoing.CustomerId;
incomingTireStack.VehicleId ??= outgoing.VehicleId;
incomingTireStack.CatalogItemId = null;
incomingTireStack.ItemCount = TireStack.TiresPerStack;
incomingTireStack.PickedUpAt = null;
incomingTireStack.PickedUpByUserId = null;
incoming = await CreateAsync(incomingTireStack);
}
// 3. Store incoming stack on target (default: the location just freed)
var locationId = targetLocationId ?? freedLocationId
?? throw new InvalidOperationException("Kein Lagerplatz für die Einlagerung angegeben.");
incoming = await StoreAsync(incoming, locationId, userId);
if (incoming.StorageLocation == null)
incoming.StorageLocation = await _context.StorageLocations.FindAsync(locationId);
await transaction.CommitAsync();
_logger.LogInformation("Tire swap: {Outgoing} -> vehicle, {Incoming} -> location {Location}",
outgoing.TicketNumber, incoming.TicketNumber, incoming.StorageLocation?.Code);
return new SwapResult
{
Outgoing = outgoing,
Incoming = incoming,
FreedLocationCode = freedLocationCode,
NewLocationCode = incoming.StorageLocation?.Code
};
}
catch
{
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
throw;
}
});
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Der Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen.");
}
}
public async Task<bool> DeleteAsync(Guid id)
{
var tireStack = await _context.TireStacks.FindAsync(id);
@@ -501,4 +621,34 @@ public class TireStackService : ITireStackService
.OrderBy(ts => ts.ExpectedPickupDate)
.ToListAsync();
}
public async Task<TireInspection> AddInspectionAsync(TireInspection inspection)
{
var exists = await _context.TireStacks.AnyAsync(ts => ts.Id == inspection.TireStackId);
if (!exists)
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
if (inspection.Id == Guid.Empty) inspection.Id = Guid.NewGuid();
inspection.CreatedAt = DateTime.UtcNow;
_context.TireInspections.Add(inspection);
await _context.SaveChangesAsync();
return inspection;
}
public async Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId)
{
return await _context.TireInspections
.Where(i => i.TireStackId == tireStackId)
.OrderByDescending(i => i.MeasuredAt ?? i.CreatedAt)
.ToListAsync();
}
public async Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId)
{
return await _context.TireInspections
.Where(i => i.TireStackId == tireStackId)
.OrderByDescending(i => i.MeasuredAt ?? i.CreatedAt)
.FirstOrDefaultAsync();
}
}
+4
View File
@@ -13,5 +13,9 @@
"Username": "spaethf",
"Password": "DDsm4$n3",
"DisplayName": "Administrator"
},
"LaunchReport": {
"ApiUrl": "https://files.cnlaunch.com/api/ttm/getTtmReportData",
"ShareUrl": "https://files.cnlaunch.com/assets/TTS01DetectReportShare.html?dataRecordSn="
}
}
+88
View File
@@ -0,0 +1,88 @@
// QR code scanner interop (html5-qrcode) for Blazor.
// Usage from .NET: qrScanner.start(elementId, dotNetRef) / qrScanner.stop()
window.qrScanner = (function () {
let scanner = null;
let lastText = null;
let lastTime = 0;
async function ensureLib() {
if (window.Html5Qrcode) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'lib/html5-qrcode/html5-qrcode.min.js';
s.onload = resolve;
s.onerror = () => reject(new Error('QR-Bibliothek konnte nicht geladen werden.'));
document.head.appendChild(s);
});
}
async function start(elementId, dotNetRef) {
await stop();
await ensureLib();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Kamera wird von diesem Browser nicht unterstützt (HTTPS erforderlich).');
}
scanner = new Html5Qrcode(elementId, {
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
verbose: false
});
const config = {
fps: 10,
qrbox: (w, h) => {
const size = Math.max(180, Math.min(w, h) * 0.7);
return { width: size, height: size };
},
aspectRatio: 1.0
};
const onSuccess = (text) => {
const now = Date.now();
if (text === lastText && now - lastTime < 2500) return;
lastText = text;
lastTime = now;
dotNetRef.invokeMethodAsync('OnQrScanned', text);
};
try {
await scanner.start({ facingMode: 'environment' }, config, onSuccess, () => { });
} catch (e) {
// Fallback: any camera
const cams = await Html5Qrcode.getCameras();
if (!cams || cams.length === 0) throw new Error('Keine Kamera gefunden.');
await scanner.start(cams[0].id, config, onSuccess, () => { });
}
}
async function stop() {
if (!scanner) return;
try {
if (scanner.isScanning) await scanner.stop();
scanner.clear();
} catch (e) { /* ignore */ }
scanner = null;
lastText = null;
}
// Short audio feedback ("success" | "error") via Web Audio, no asset required.
function playSound(type) {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = type === 'error' ? 220 : 880;
gain.gain.value = 0.08;
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + (type === 'error' ? 0.25 : 0.12));
osc.onended = () => ctx.close().catch(() => { });
} catch (e) { /* audio is best-effort */ }
}
return { start, stop, playSound };
})();
@@ -0,0 +1 @@
var __Html5QrcodeLibrary__;(()=>{var t={449:function(t,e,r){!function(t){"use strict";function e(t){return null==t}var n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])};var i,o=function(t){function e(e){var r,n,i,o=this.constructor,s=t.call(this,e)||this;return Object.defineProperty(s,"name",{value:o.name,enumerable:!1}),r=s,n=o.prototype,(i=Object.setPrototypeOf)?i(r,n):r.__proto__=n,function(t,e){void 0===e&&(e=t.constructor);var r=Error.captureStackTrace;r&&r(t,e)}(s),s}return function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(e,t),e}(Error);class s extends o{constructor(t=undefined){super(t),this.message=t}getKind(){return this.constructor.kind}}s.kind="Exception";class a extends s{}a.kind="ArgumentException";class c extends s{}c.kind="IllegalArgumentException";class l{constructor(t){if(this.binarizer=t,null===t)throw new c("Binarizer must be non-null.")}getWidth(){return this.binarizer.getWidth()}getHeight(){return this.binarizer.getHeight()}getBlackRow(t,e){return this.binarizer.getBlackRow(t,e)}getBlackMatrix(){return null!==this.matrix&&void 0!==this.matrix||(this.matrix=this.binarizer.getBlackMatrix()),this.matrix}isCropSupported(){return this.binarizer.getLuminanceSource().isCropSupported()}crop(t,e,r,n){const i=this.binarizer.getLuminanceSource().crop(t,e,r,n);return new l(this.binarizer.createBinarizer(i))}isRotateSupported(){return this.binarizer.getLuminanceSource().isRotateSupported()}rotateCounterClockwise(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise();return new l(this.binarizer.createBinarizer(t))}rotateCounterClockwise45(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise45();return new l(this.binarizer.createBinarizer(t))}toString(){try{return this.getBlackMatrix().toString()}catch(t){return""}}}class h extends s{static getChecksumInstance(){return new h}}h.kind="ChecksumException";class u{constructor(t){this.source=t}getLuminanceSource(){return this.source}getWidth(){return this.source.getWidth()}getHeight(){return this.source.getHeight()}}class d{static arraycopy(t,e,r,n,i){for(;i--;)r[n++]=t[e++]}static currentTimeMillis(){return Date.now()}}class f extends s{}f.kind="IndexOutOfBoundsException";class g extends f{constructor(t=undefined,e=undefined){super(e),this.index=t,this.message=e}}g.kind="ArrayIndexOutOfBoundsException";class w{static fill(t,e){for(let r=0,n=t.length;r<n;r++)t[r]=e}static fillWithin(t,e,r,n){w.rangeCheck(t.length,e,r);for(let i=e;i<r;i++)t[i]=n}static rangeCheck(t,e,r){if(e>r)throw new c("fromIndex("+e+") > toIndex("+r+")");if(e<0)throw new g(e);if(r>t)throw new g(r)}static asList(...t){return t}static create(t,e,r){return Array.from({length:t}).map((t=>Array.from({length:e}).fill(r)))}static createInt32Array(t,e,r){return Array.from({length:t}).map((t=>Int32Array.from({length:e}).fill(r)))}static equals(t,e){if(!t)return!1;if(!e)return!1;if(!t.length)return!1;if(!e.length)return!1;if(t.length!==e.length)return!1;for(let r=0,n=t.length;r<n;r++)if(t[r]!==e[r])return!1;return!0}static hashCode(t){if(null===t)return 0;let e=1;for(const r of t)e=31*e+r;return e}static fillUint8Array(t,e){for(let r=0;r!==t.length;r++)t[r]=e}static copyOf(t,e){return t.slice(0,e)}static copyOfUint8Array(t,e){if(t.length<=e){const r=new Uint8Array(e);return r.set(t),r}return t.slice(0,e)}static copyOfRange(t,e,r){const n=r-e,i=new Int32Array(n);return d.arraycopy(t,e,i,0,n),i}static binarySearch(t,e,r){void 0===r&&(r=w.numberComparator);let n=0,i=t.length-1;for(;n<=i;){const o=i+n>>1,s=r(e,t[o]);if(s>0)n=o+1;else{if(!(s<0))return o;i=o-1}}return-n-1}static numberComparator(t,e){return t-e}}class m{static numberOfTrailingZeros(t){let e;if(0===t)return 32;let r=31;return e=t<<16,0!==e&&(r-=16,t=e),e=t<<8,0!==e&&(r-=8,t=e),e=t<<4,0!==e&&(r-=4,t=e),e=t<<2,0!==e&&(r-=2,t=e),r-(t<<1>>>31)}static numberOfLeadingZeros(t){if(0===t)return 32;let e=1;return t>>>16==0&&(e+=16,t<<=16),t>>>24==0&&(e+=8,t<<=8),t>>>28==0&&(e+=4,t<<=4),t>>>30==0&&(e+=2,t<<=2),e-=t>>>31,e}static toHexString(t){return t.toString(16)}static toBinaryString(t){return String(parseInt(String(t),2))}static bitCount(t){return t=(t=(858993459&(t-=t>>>1&1431655765))+(t>>>2&858993459))+(t>>>4)&252645135,63&(t+=t>>>8)+(t>>>16)}static truncDivision(t,e){return Math.trunc(t/e)}static parseInt(t,e=undefined){return parseInt(t,e)}}m.MIN_VALUE_32_BITS=-2147483648,m.MAX_VALUE=Number.MAX_SAFE_INTEGER;class p{constructor(t,e){void 0===t?(this.size=0,this.bits=new Int32Array(1)):(this.size=t,this.bits=null==e?p.makeArray(t):e)}getSize(){return this.size}getSizeInBytes(){return Math.floor((this.size+7)/8)}ensureCapacity(t){if(t>32*this.bits.length){const e=p.makeArray(t);d.arraycopy(this.bits,0,e,0,this.bits.length),this.bits=e}}get(t){return 0!=(this.bits[Math.floor(t/32)]&1<<(31&t))}set(t){this.bits[Math.floor(t/32)]|=1<<(31&t)}fLine truncated