Files

1313 lines
56 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/storage-locations"
@using Lagerbestand.Components.Shared
@inject IStorageLocationService StorageLocationService
@inject ILocationCodeService LocationCodeService
@inject ITireStackService TireStackService
@inject UserSessionService UserSession
@inject IStorageRowPdfService RowPdfService
@inject IJSRuntime JS
<PageTitle>Lagerplätze - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Lagerplätze - Reifen Späth Lagerverwaltung"
Description="Verwaltung der Lagerplätze in der Reifen Späth Lagerverwaltung."
Robots="noindex, nofollow" />
@if (!UserSession.IsAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Administratoren zugänglich.</p>
<a href="/tire-storage" class="btn btn-primary">Zur Lagerübersicht</a>
</section>
}
else
{
<main class="locations-page" role="main" @onkeydown="HandleKeyDown">
<div class="page-header">
<div class="header-left">
<h1>Lagerplätze</h1>
<span class="location-count">@filteredLocations.Count von @allLocations.Count</span>
</div>
<div class="header-actions">
<div class="view-toggle">
<button class="view-btn @(viewMode == "grid" ? "active" : "")" @onclick="SetGridView" title="Rasteransicht">
<span class="icon">⊞</span>
</button>
<button class="view-btn @(viewMode == "table" ? "active" : "")" @onclick="SetTableView" title="Tabellenansicht">
<span class="icon">☰</span>
</button>
</div>
<button class="btn btn-primary" @onclick="ShowAddLocation">
+ Neuer Platz
</button>
</div>
</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 && l.IsAvailable)</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="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>
<select class="form-control row-select" @bind="selectedRow" @bind:after="ApplyFilter" title="Nach Reihe filtern">
<option value="">Alle Reihen</option>
@for (int r = StorageLocation.MinRow; r <= StorageLocation.MaxRowLager; r++)
{
<option value="@r">Reihe @r</option>
}
</select>
@if (viewMode == "grid")
{
<button class="btn btn-outline btn-sm" @onclick="ToggleAllRows" title="Alle Reihen ein-/ausklappen">
@(allRowsCollapsed ? "▶ Alle aufklappen" : "▼ Alle zuklappen")
</button>
}
@if (HasActiveFilters)
{
<button class="btn btn-outline btn-sm clear-filters" @onclick="ResetFilters" title="Filter zurücksetzen">
✕ Filter löschen
</button>
}
</div>
</div>
@* Loading State *@
@if (isLoading)
{
<div class="loading-state">
<div class="spinner"></div>
<span>Lade Lagerplätze...</span>
</div>
}
else if (filteredLocations.Count == 0)
{
<div class="empty-state">
<div class="empty-icon">📦</div>
<h3>Keine Lagerplätze</h3>
<p>@(string.IsNullOrEmpty(searchTerm) ? "Erstellen Sie Lagerplätze für Ihre Reifenlagerung." : "Keine Ergebnisse für Ihre Suche.")</p>
@if (string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-primary" @onclick="ShowAddLocation">Ersten Platz anlegen</button>
}
</div>
}
else if (viewMode == "grid")
{
@* Grid View - grouped by area and row *@
@foreach (var areaGroup in filteredLocations.GroupBy(l => l.Area).OrderBy(g => (int)g.Key))
{
<div class="area-section @(areaGroup.Key == AreaType.Lager ? "lager" : "werkstatt")">
<h2 class="area-title">
@(areaGroup.Key == AreaType.Lager ? "🏭" : "🔧") @areaGroup.Key.GetDisplayName()
</h2>
@foreach (var rowGroup in areaGroup.GroupBy(l => l.Row).OrderBy(g => g.Key))
{
var rowKey = $"{areaGroup.Key}-{rowGroup.Key}";
var isCollapsed = collapsedRows.Contains(rowKey);
var occupiedCount = rowGroup.Count(l => l.IsOccupied);
var totalCount = rowGroup.Count();
<div class="row-section @(isCollapsed ? "collapsed" : "")">
<div class="row-header-wrap">
<button type="button" class="row-header" @onclick="() => ToggleRow(rowKey)">
<span class="row-chevron">@(isCollapsed ? "▶" : "▼")</span>
<h3 class="row-title">Reihe @rowGroup.Key</h3>
<span class="row-summary">@occupiedCount / @totalCount belegt</span>
</button>
<button type="button" class="btn btn-outline btn-sm row-print-btn"
title="Reihe als PDF drucken"
disabled="@(printingRowKey != null)"
@onclick="() => PrintRow(areaGroup.Key, rowGroup.Key)"
@onclick:stopPropagation>
@(printingRowKey == rowKey ? "⏳" : "🖨") PDF
</button>
</div>
@if (printErrorRowKey == rowKey && printError != null)
{
<div class="row-print-error" role="alert">@printError</div>
}
@if (!isCollapsed)
{
@foreach (var posGroup in rowGroup.GroupBy(l => l.Position).OrderBy(g => (int)g.Key))
{
<h4 class="position-title">@posGroup.Key.GetDisplayName()</h4>
<div class="location-grid">
@foreach (var location in posGroup.OrderBy(l => l.SlotNumber))
{
var storedTireStack = location.OccupiedTireStack;
<div class="location-card @(location.IsOccupied ? "occupied" : "available") @(!location.IsAvailable ? "blocked" : "")"
@onclick="() => ShowLocationDetails(location)">
<div class="card-header">
<span class="location-code">@location.Code</span>
<span class="slot-number">@location.SlotNumber</span>
</div>
@if (location.IsOccupied && storedTireStack != null)
{
<div class="card-content">
<span class="customer-name" title="@(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager")">
@(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager")
</span>
@if (storedTireStack.Vehicle != null)
{
<span class="vehicle-line" title="@storedTireStack.Vehicle.ShortName">
<span class="vehicle-model">🚗 @storedTireStack.Vehicle.ShortName</span>
</span>
}
<span class="tire-info">
<span class="season-icon">@(storedTireStack.Season == TireSeason.Sommer ? "☀️" : storedTireStack.Season == TireSeason.Winter ? "❄️" : "🔄")</span>
@if (!string.IsNullOrEmpty(storedTireStack.TireSize))
{
<span class="tire-size-compact">@storedTireStack.TireSize</span>
}
</span>
</div>
}
else
{
<div class="card-content empty">
<span class="empty-text">Frei</span>
</div>
}
@if (!location.IsAvailable)
{
<div class="blocked-badge">Gesperrt</div>
}
</div>
}
</div>
}
}
</div>
}
</div>
}
}
else
{
@* Table View *@
<div class="location-table-wrapper">
<table class="location-table">
<thead>
<tr>
<th class="col-status"></th>
<th class="col-code">Code</th>
<th class="col-area">Gebäude</th>
<th class="col-row">Reihe</th>
<th class="col-position">Seite/Ebene</th>
<th class="col-slot">Platz</th>
<th class="col-customer">Kunde/Inhalt</th>
<th class="col-tires">Reifen</th>
<th class="col-season">Saison</th>
<th class="col-stored">Eingelagert</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@foreach (var location in filteredLocations)
{
var storedTireStack = location.OccupiedTireStack;
<tr class="@(location.IsOccupied ? "occupied" : "available") @(!location.IsAvailable ? "blocked" : "")"
@onclick="() => ShowLocationDetails(location)">
<td class="col-status">
<span class="status-dot @(location.IsOccupied ? "occupied" : location.IsAvailable ? "available" : "blocked")"></span>
</td>
<td class="col-code">
<strong class="code-mono">@location.Code</strong>
</td>
<td class="col-area">
<span class="area-badge @(location.Area == AreaType.Lager ? "lager" : "werkstatt")">
@(location.Area == AreaType.Lager ? "🏭" : "🔧") @location.Area.GetDisplayName()
</span>
</td>
<td class="col-row">@location.Row</td>
<td class="col-position">@location.Position.GetShortName()</td>
<td class="col-slot">@location.SlotNumber</td>
<td class="col-customer">
@if (storedTireStack != null)
{
@if (storedTireStack.Customer != null)
{
<span class="customer">@storedTireStack.Customer.FullName</span>
@if (storedTireStack.Vehicle != null)
{
<span class="vehicle-info">🚗 @storedTireStack.Vehicle.ShortName</span>
}
}
else
{
<span class="inventory">📦 @(storedTireStack.CatalogItem?.Name ?? "Lager")</span>
}
}
else
{
<span class="empty-dash">-</span>
}
</td>
<td class="col-tires">
@if (storedTireStack != null)
{
<span class="tire-count">1 (@TireStack.TiresPerStack)</span>
@if (!string.IsNullOrEmpty(storedTireStack.TireSize))
{
<span class="tire-size">@storedTireStack.TireSize</span>
}
}
else
{
<span class="empty-dash">-</span>
}
</td>
<td class="col-season">
@if (storedTireStack != null)
{
<span class="season @storedTireStack.Season.ToString().ToLower()">
@(storedTireStack.Season == TireSeason.Sommer ? "☀️" : storedTireStack.Season == TireSeason.Winter ? "❄️" : "🔄")
</span>
}
else
{
<span class="empty-dash">-</span>
}
</td>
<td class="col-stored">
@if (location.OccupiedSince.HasValue)
{
<span class="date">@location.OccupiedSince.Value.ToLocalTime().ToString("dd.MM.yy")</span>
}
else
{
<span class="empty-dash">-</span>
}
</td>
<td class="col-actions">
<button class="btn-icon" @onclick="() => ShowLocationDetails(location)" @onclick:stopPropagation="true" title="Details">
⋯
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* Batch Create Section *@
<div class="batch-section @(showBatchCreate ? "expanded" : "")">
<button class="batch-toggle" @onclick="ToggleBatchCreate">
<span>Schnell-Erstellung</span>
<span class="toggle-icon">@(showBatchCreate ? "−" : "+")</span>
</button>
@if (showBatchCreate)
{
<div class="batch-content">
<p>Erstellen Sie mehrere Lagerplätze auf einmal – pro Platz werden alle 4 Positionen (links/rechts × oben/unten) angelegt.</p>
<div class="batch-form">
<div class="form-row">
<div class="form-group">
<label>Gebäude</label>
<select class="form-control" @bind="batchArea" @bind:after="OnBatchAreaChanged">
<option value="@AreaType.Werkstatt">Gebäude 1 – Werkstatt</option>
<option value="@AreaType.Lager">Gebäude 2 – Lager</option>
</select>
</div>
<div class="form-group">
<label>Reihe</label>
<select class="form-control" @bind="batchRow">
@for (int r = StorageLocation.MinRow; r <= batchArea.GetMaxRow(); r++)
{
<option value="@r">@r</option>
}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Plätze (von-bis)</label>
<div class="range-input">
<input type="number" class="form-control" @bind="batchSlotStart" min="@StorageLocation.MinSlot" max="@StorageLocation.MaxSlot" />
<span>-</span>
<input type="number" class="form-control" @bind="batchSlotEnd" min="@StorageLocation.MinSlot" max="@StorageLocation.MaxSlot" />
</div>
</div>
</div>
<div class="batch-preview">
<span class="preview-label">Vorschau:</span>
<span class="preview-codes">
@LocationCodeService.GenerateLocationCode(batchArea, batchRow, ShelfPosition.LinksOben, batchSlotStart) ... @LocationCodeService.GenerateLocationCode(batchArea, batchRow, ShelfPosition.RechtsUnten, batchSlotEnd)
</span>
</div>
<div class="batch-summary-row">
<div class="summary-info">
<span class="summary-count">@batchCount Plätze</span>
<span class="summary-detail">= @(batchCount * TireStack.TiresPerStack) Reifen Kapazität</span>
</div>
<button class="btn btn-primary" @onclick="CreateBatchLocations" disabled="@(!CanCreateBatch || isCreatingBatch)">
@if (isCreatingBatch)
{
<span class="spinner"></span>
}
Erstellen
</button>
</div>
@if (!string.IsNullOrEmpty(batchError))
{
<div class="alert @(batchError.Contains("erstellt") ? "alert-success" : "alert-danger")">@batchError</div>
}
</div>
</div>
}
</div>
@* Location Details Modal *@
@if (selectedLocation != null)
{
<div class="modal-backdrop" @onclick="CloseDetails"></div>
<div class="app-modal" @onclick="CloseDetails" role="dialog" aria-modal="true" aria-labelledby="details-modal-title">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content details-modal">
<div class="modal-header">
<div class="modal-title-group">
<div class="code-badge">
<span class="code-icon">📍</span>
<span class="code-text">@selectedLocation.Code</span>
</div>
<span id="details-modal-title" class="location-description">@selectedLocation.DisplayName</span>
</div>
<button class="btn-close" @onclick="CloseDetails" aria-label="Schließen">✕</button>
</div>
<div class="modal-body">
<div class="status-banner @(selectedLocation.IsOccupied ? "occupied" : selectedLocation.IsAvailable ? "available" : "blocked")">
<span class="status-icon">
@(selectedLocation.IsOccupied ? "📦" : selectedLocation.IsAvailable ? "✓" : "🚫")
</span>
<span class="status-text">
@(selectedLocation.IsOccupied ? "Belegt" : selectedLocation.IsAvailable ? "Frei & Verfügbar" : "Gesperrt")
</span>
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Gebäude</span>
<span class="value">
<span class="area-icon">@(selectedLocation.Area == AreaType.Lager ? "🏭" : "🔧")</span>
@selectedLocation.Area.GetDisplayName()
</span>
</div>
<div class="detail-item">
<span class="label">Reihe</span>
<span class="value">@selectedLocation.Row</span>
</div>
<div class="detail-item">
<span class="label">Seite/Ebene</span>
<span class="value">@selectedLocation.Position.GetDisplayName()</span>
</div>
<div class="detail-item">
<span class="label">Platz</span>
<span class="value">@selectedLocation.SlotNumber</span>
</div>
@if (selectedLocation.OccupiedSince.HasValue)
{
<div class="detail-item">
<span class="label">Belegt seit</span>
<span class="value">@selectedLocation.OccupiedSince.Value.ToLocalTime().ToString("dd.MM.yyyy")</span>
</div>
}
</div>
@if (selectedLocation.IsOccupied && selectedLocation.OccupiedTireStack != null)
{
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">
@(ts.Customer?.FullName ?? ts.CatalogItem?.Name ?? "Lagerbestand")
</span>
<span class="stored-ticket">@ts.TicketNumber</span>
</div>
<div class="stored-details">
<span class="detail-chip">1 Stapel (@TireStack.TiresPerStack Reifen)</span>
@if (!string.IsNullOrEmpty(ts.TireSize))
{
<span class="detail-chip">@ts.TireSize</span>
}
<span class="detail-chip season-chip @ts.Season.ToString().ToLower()">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄") @ts.Season
</span>
@if (ts.WithRims)
{
<span class="detail-chip">mit Felgen</span>
}
</div>
@if (!string.IsNullOrEmpty(ts.TireBrand))
{
<div class="stored-brand">@ts.TireBrand @ts.TireModel</div>
}
<div class="stored-date">
Eingelagert am @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy") • @ts.DaysStored Tage
</div>
</div>
}
</div>
}
@if (!string.IsNullOrEmpty(selectedLocation.Notes))
{
<div class="notes-section">
<span class="label">📝 Notizen</span>
<p>@selectedLocation.Notes</p>
</div>
}
@if (!string.IsNullOrEmpty(detailsError))
{
<div class="alert alert-danger">@detailsError</div>
}
</div>
<div class="modal-footer">
@if (!selectedLocation.IsOccupied)
{
<button class="btn btn-danger" @onclick="() => ConfirmDeleteLocation(selectedLocation)" disabled="@isDeleting">
@if (isDeleting)
{
<span class="spinner small"></span>
}
else
{
<span>🗑️</span>
}
Löschen
</button>
}
<div class="footer-spacer"></div>
<button class="btn btn-outline" @onclick="() => ToggleAvailability(selectedLocation)">
@(selectedLocation.IsAvailable ? "🚫 Sperren" : "✓ Freigeben")
</button>
<button class="btn btn-primary" @onclick="EditLocation">
✏️ Bearbeiten
</button>
</div>
</div>
</div>
</div>
}
@* Delete Confirmation Modal *@
@if (showDeleteConfirm && locationToDelete != null)
{
<div class="modal-backdrop" @onclick="CancelDelete"></div>
<div class="app-modal" @onclick="CancelDelete" role="alertdialog" aria-modal="true" aria-labelledby="delete-modal-title">
<div class="modal-dialog modal-dialog-sm" @onclick:stopPropagation="true">
<div class="modal-content confirm-modal">
<div class="modal-header">
<h3 id="delete-modal-title">Lagerplatz löschen?</h3>
<button class="btn-close" @onclick="CancelDelete" aria-label="Schließen">✕</button>
</div>
<div class="modal-body">
<div class="confirm-icon">⚠️</div>
<p class="confirm-text">
Möchten Sie den Lagerplatz <strong>@locationToDelete.Code</strong> wirklich löschen?
</p>
<p class="confirm-subtext">
@locationToDelete.DisplayName
</p>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @onclick="CancelDelete">Abbrechen</button>
<button class="btn btn-danger" @onclick="DeleteLocation" disabled="@isDeleting">
@if (isDeleting)
{
<span class="spinner small"></span>
}
Endgültig löschen
</button>
</div>
</div>
</div>
</div>
}
@* Add/Edit Location Modal - Improved UI/UX *@
@if (showModal)
{
<div class="modal-backdrop" @onclick="HandleModalBackdropClick"></div>
<div class="app-modal" @onclick="HandleModalBackdropClick" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content edit-modal">
<div class="modal-header">
<div class="modal-title-wrapper">
<h3 id="edit-modal-title">@(isEditing ? "Lagerplatz bearbeiten" : "Neuen Stellplatz anlegen")</h3>
@if (!isEditing)
{
<span class="modal-subtitle">Füllen Sie alle Pflichtfelder aus, um einen neuen Stellplatz zu erstellen.</span>
}
</div>
<button class="btn-close" @onclick="CloseModal" aria-label="Schließen" title="Schließen (Esc)">✕</button>
</div>
<div class="modal-body">
@* Success Message *@
@if (showSuccessMessage)
{
<div class="alert alert-success success-message" role="status">
<span class="success-icon">✓</span>
<span>@successMessage</span>
</div>
}
@* Location Code Input Component *@
<LocationCodeInput
@ref="locationCodeInputRef"
@bind-SelectedArea="editArea"
@bind-SelectedRow="editRow"
@bind-SelectedPosition="editPosition"
@bind-SelectedSlot="editSlot"
@bind-LocationCode="editCode"
ShowAvailability="@(!isEditing)"
OnValidationChanged="OnFormValidationChanged"
OnAvailabilityChanged="OnAvailabilityChanged" />
@* Notes Field - Optional *@
<div class="form-group notes-group">
<label for="location-notes">
Notizen
<span class="optional-indicator">(optional)</span>
</label>
<textarea id="location-notes"
class="form-control"
rows="2"
@bind="editNotes"
placeholder="Zusätzliche Informationen zum Lagerplatz..."
aria-describedby="notes-hint"></textarea>
<span id="notes-hint" class="field-hint">z.B. Besonderheiten, Einschränkungen</span>
</div>
@* Error Message *@
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert">
<span class="error-icon">⚠</span>
<span>@errorMessage</span>
</div>
}
</div>
<div class="modal-footer">
<button class="btn btn-outline" @onclick="CloseModal" type="button">
Abbrechen
</button>
<button class="btn btn-primary"
@onclick="SaveLocation"
disabled="@(!CanSave || isSaving)"
type="button">
@if (isSaving)
{
<span class="spinner small"></span>
<span>@(isEditing ? "Speichern..." : "Anlegen...")</span>
}
else
{
<span>@(isEditing ? "Speichern" : "Stellplatz anlegen")</span>
}
</button>
</div>
</div>
</div>
</div>
}
</main>
}
@code {
// Status filter constants
private const string StatusAvailable = "available";
private const string StatusOccupied = "occupied";
private List<StorageLocation> allLocations = new();
private List<StorageLocation> filteredLocations = new();
private AreaType? selectedArea;
private string? selectedRow;
private string? statusFilter;
private string searchTerm = "";
private string viewMode = "grid";
private readonly HashSet<string> collapsedRows = new();
private bool isLoading = true;
private bool showBatchCreate = false;
// Details modal
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;
private bool isDeleting;
// Add/Edit modal
private bool showModal;
private bool isEditing;
private Guid editId;
private AreaType editArea = AreaType.Lager;
private int editRow = 1;
private ShelfPosition editPosition = ShelfPosition.LinksOben;
private int editSlot = 1;
private string editCode = "";
private string? editNotes;
private string? errorMessage;
private bool isSaving;
private bool isFormValid;
private bool isLocationAvailable;
private bool showSuccessMessage;
private string? successMessage;
private LocationCodeInput? locationCodeInputRef;
private void ToggleRow(string rowKey)
{
if (!collapsedRows.Add(rowKey))
{
collapsedRows.Remove(rowKey);
}
}
private string? printingRowKey;
private string? printErrorRowKey;
private string? printError;
private async Task PrintRow(AreaType area, int row)
{
var rowKey = $"{area}-{row}";
if (printingRowKey != null) return;
printingRowKey = rowKey;
printError = null;
printErrorRowKey = null;
try
{
var rowLocations = allLocations
.Where(l => l.Area == area && l.Row == row)
.ToList();
var pdf = RowPdfService.GenerateRowPdf(area, row, rowLocations);
var fileName = $"Lagerplan_{area.GetDisplayName()}_Reihe{row}_{DateTime.Now:yyyyMMdd-HHmm}.pdf";
await JS.InvokeVoidAsync("appInterop.downloadFile", fileName, Convert.ToBase64String(pdf), "application/pdf");
}
catch (Exception ex)
{
printErrorRowKey = rowKey;
printError = $"PDF konnte nicht erstellt werden: {ex.Message}";
}
finally
{
printingRowKey = null;
}
}
// Batch creation
private AreaType batchArea = AreaType.Lager;
private int batchRow = 1;
private int batchSlotStart = 1;
private int batchSlotEnd = 10;
private bool isCreatingBatch;
private string? batchError;
private bool CanSave => !string.IsNullOrWhiteSpace(editCode) &&
LocationCodeService.ValidateLocationCode(editCode).IsValid &&
(isEditing || isLocationAvailable);
private bool CanCreateBatch =>
batchSlotEnd >= batchSlotStart &&
batchSlotStart >= StorageLocation.MinSlot &&
batchSlotEnd <= StorageLocation.MaxSlot;
private int batchCount => CanCreateBatch ? (batchSlotEnd - batchSlotStart + 1) * ShelfPositionExtensions.All.Length : 0;
private void OnBatchAreaChanged()
{
if (batchRow > batchArea.GetMaxRow())
batchRow = batchArea.GetMaxRow();
}
protected override async Task OnInitializedAsync()
{
if (UserSession.IsAdmin)
{
await LoadLocations();
}
}
private void HandleKeyDown(KeyboardEventArgs e)
{
// Close modals on Escape key
if (e.Key == "Escape")
{
if (showModal)
{
CloseModal();
}
else if (showDeleteConfirm)
{
CancelDelete();
}
else if (selectedLocation != null)
{
CloseDetails();
}
}
}
private void HandleModalBackdropClick()
{
// Only close if form is empty (not dirty)
if (!IsFormDirty())
{
CloseModal();
}
}
private bool IsFormDirty()
{
if (isEditing) return true; // Always confirm when editing
return !string.IsNullOrEmpty(editNotes) || editSlot != 1;
}
private void OnFormValidationChanged(bool isValid)
{
isFormValid = isValid;
StateHasChanged();
}
private void OnAvailabilityChanged(bool available)
{
isLocationAvailable = available;
StateHasChanged();
}
private async Task LoadLocations()
{
isLoading = true;
StateHasChanged();
try
{
allLocations = await StorageLocationService.GetAllAsync();
ApplyFilter();
}
finally
{
isLoading = false;
}
}
private void SetGridView() => viewMode = "grid";
private void SetTableView() => viewMode = "table";
private void ToggleBatchCreate() => showBatchCreate = !showBatchCreate;
private void FilterByArea(AreaType? area)
{
selectedArea = area;
ApplyFilter();
}
private void FilterByStatus(string? status)
{
statusFilter = status;
ApplyFilter();
}
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()
{
var query = allLocations.AsEnumerable();
if (selectedArea.HasValue)
{
query = query.Where(l => l.Area == selectedArea.Value);
}
if (!string.IsNullOrEmpty(selectedRow) && int.TryParse(selectedRow, out var row))
{
query = query.Where(l => l.Row == row);
}
if (statusFilter == StatusAvailable)
{
query = query.Where(l => !l.IsOccupied && l.IsAvailable);
}
else if (statusFilter == StatusOccupied)
{
query = query.Where(l => l.IsOccupied);
}
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower().Trim();
query = query.Where(l =>
l.Code.ToLower().Contains(term) ||
l.Area.GetDisplayName().ToLower().Contains(term) ||
(l.OccupiedTireStack?.Customer?.FullName?.ToLower().Contains(term) ?? false) ||
(l.OccupiedTireStack?.Vehicle?.Make?.ToLower().Contains(term) ?? false) ||
(l.OccupiedTireStack?.Vehicle?.Model?.ToLower().Contains(term) ?? false)
);
}
filteredLocations = query
.OrderBy(l => l.Area)
.ThenBy(l => l.Row)
.ThenBy(l => l.Position)
.ThenBy(l => l.SlotNumber)
.ToList();
}
private async Task ShowLocationDetails(StorageLocation location)
{
detailsError = null;
isEditingTireStack = false;
editTireStack = null;
selectedLocation = await StorageLocationService.GetByIdAsync(location.Id);
}
private void CloseDetails()
{
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()
{
// Find next available location
var next = await LocationCodeService.GetNextAvailableSlotAsync(AreaType.Lager, 1);
editId = Guid.Empty;
editArea = next?.Area ?? AreaType.Lager;
editRow = next?.Row ?? 1;
editPosition = next?.Position ?? ShelfPosition.LinksOben;
editSlot = next?.SlotNumber ?? 1;
editCode = LocationCodeService.GenerateLocationCode(editArea, editRow, editPosition, editSlot);
editNotes = null;
isEditing = false;
errorMessage = null;
isSaving = false;
showSuccessMessage = false;
isLocationAvailable = true;
showModal = true;
}
private void EditLocation()
{
if (selectedLocation == null) return;
editId = selectedLocation.Id;
editArea = selectedLocation.Area;
editRow = selectedLocation.Row;
editPosition = selectedLocation.Position;
editSlot = selectedLocation.SlotNumber;
editCode = selectedLocation.Code;
editNotes = selectedLocation.Notes;
isEditing = true;
errorMessage = null;
isSaving = false;
showSuccessMessage = false;
selectedLocation = null;
showModal = true;
}
private void CloseModal()
{
showModal = false;
errorMessage = null;
showSuccessMessage = false;
isSaving = false;
}
private async Task SaveLocation()
{
if (!CanSave || isSaving) return;
// Validate form
if (locationCodeInputRef != null && !locationCodeInputRef.ValidateAll())
{
return;
}
isSaving = true;
errorMessage = null;
StateHasChanged();
try
{
if (isEditing)
{
var existing = allLocations.First(l => l.Id == editId);
existing.Area = editArea;
existing.Row = editRow;
existing.Position = editPosition;
existing.SlotNumber = editSlot;
existing.Notes = editNotes;
existing.GenerateCode();
await StorageLocationService.UpdateAsync(existing);
// Show success and close
ApplyFilter();
CloseModal();
}
else
{
var location = new StorageLocation
{
Area = editArea,
Row = editRow,
Position = editPosition,
SlotNumber = editSlot,
Notes = editNotes
};
location.GenerateCode();
var created = await StorageLocationService.CreateAsync(location);
allLocations.Add(created);
// Show success message
successMessage = $"Stellplatz {created.Code} erfolgreich angelegt";
showSuccessMessage = true;
StateHasChanged();
// Auto-close after delay
await Task.Delay(1500);
ApplyFilter();
CloseModal();
}
}
catch (Exception ex)
{
errorMessage = ex.Message.Contains("existiert")
? "Dieser Stellplatz ist bereits belegt"
: $"Fehler beim Anlegen des Stellplatzes: {ex.Message}";
}
finally
{
isSaving = false;
}
}
private async Task ToggleAvailability(StorageLocation location)
{
location.IsAvailable = !location.IsAvailable;
location.UpdatedAt = DateTime.UtcNow;
await StorageLocationService.UpdateAsync(location);
selectedLocation = null;
await LoadLocations();
}
private void ConfirmDeleteLocation(StorageLocation location)
{
locationToDelete = location;
showDeleteConfirm = true;
selectedLocation = null;
}
private void CancelDelete()
{
showDeleteConfirm = false;
locationToDelete = null;
}
private async Task DeleteLocation()
{
if (locationToDelete == null) return;
isDeleting = true;
StateHasChanged();
try
{
var success = await StorageLocationService.DeleteAsync(locationToDelete.Id);
if (success)
{
allLocations.RemoveAll(l => l.Id == locationToDelete.Id);
ApplyFilter();
CancelDelete();
}
else
{
detailsError = "Lagerplatz konnte nicht gelöscht werden.";
}
}
catch (Exception ex)
{
detailsError = $"Fehler beim Löschen: {ex.Message}";
}
finally
{
isDeleting = false;
}
}
private async Task CreateBatchLocations()
{
if (!CanCreateBatch) return;
isCreatingBatch = true;
batchError = null;
StateHasChanged();
try
{
var created = await StorageLocationService.CreateBatchAsync(
batchArea, batchRow, batchSlotStart, batchSlotEnd);
await LoadLocations();
var skipped = batchCount - created.Count;
if (created.Count > 0)
{
batchError = $"✓ {created.Count} Plätze erstellt" + (skipped > 0 ? $", {skipped} übersprungen" : "");
}
else if (skipped > 0)
{
batchError = $"Alle {skipped} Plätze existieren bereits";
}
// Reset to next row
batchSlotStart = 1;
batchSlotEnd = 10;
}
catch (Exception ex)
{
batchError = $"Fehler: {ex.Message}";
}
finally
{
isCreatingBatch = false;
}
}
}