Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

@@ -0,0 +1,451 @@
@using Lagerbestand.Models
@using Lagerbestand.Services
@inject ILocationCodeService LocationCodeService
<div class="location-code-input" role="group" aria-labelledby="location-input-title">
<h4 id="location-input-title" class="input-section-title">Stellplatz-Daten</h4>
<div class="input-row">
@* Area Selection - Required *@
<div class="input-group area-select @(areaError != null ? "has-error" : areaTouched && areaError == null ? "has-success" : "")">
<label for="area-select">
Gebäude <span class="required-indicator" aria-hidden="true">*</span>
</label>
<select id="area-select"
class="form-control @(areaError != null ? "is-invalid" : areaTouched && areaError == null ? "is-valid" : "")"
@bind="InternalArea"
@bind:after="OnAreaChanged"
@onfocus="() => areaTouched = true"
@ref="areaSelectRef"
aria-required="true"
aria-invalid="@(areaError != null ? "true" : "false")"
aria-describedby="@(areaError != null ? "area-error" : null)">
@if (!areaTouched && InternalArea == default)
{
<option value="" disabled selected>Gebäude auswählen...</option>
}
<option value="@AreaType.Werkstatt">Gebäude 1 – Werkstatt</option>
<option value="@AreaType.Lager">Gebäude 2 – Lager</option>
</select>
@if (areaError != null)
{
<span id="area-error" class="field-error" role="alert">
<span class="error-icon">?</span> @areaError
</span>
}
else if (areaTouched)
{
<span class="field-success">
<span class="success-icon">?</span>
</span>
}
</div>
@* Row Selection - Required *@
<div class="input-group row-input @(rowError != null ? "has-error" : rowTouched && rowError == null ? "has-success" : "")">
<label for="row-select">
Reihe <span class="required-indicator" aria-hidden="true">*</span>
</label>
<select id="row-select"
class="form-control @(rowError != null ? "is-invalid" : rowTouched && rowError == null ? "is-valid" : "")"
@bind="InternalRow"
@bind:after="OnRowChanged"
@onfocus="() => rowTouched = true"
aria-required="true"
aria-invalid="@(rowError != null ? "true" : "false")"
aria-describedby="@(rowError != null ? "row-error" : null)">
@for (int r = StorageLocation.MinRow; r <= InternalArea.GetMaxRow(); r++)
{
<option value="@r">@r</option>
}
</select>
@if (rowError != null)
{
<span id="row-error" class="field-error" role="alert">
<span class="error-icon">?</span> @rowError
</span>
}
else if (rowTouched)
{
<span class="field-success">
<span class="success-icon">?</span>
</span>
}
</div>
@* Position Selection (Seite/Ebene) - Required *@
<div class="input-group position-select">
<label for="position-select">
Seite/Ebene <span class="required-indicator" aria-hidden="true">*</span>
</label>
<select id="position-select"
class="form-control"
@bind="InternalPosition"
@bind:after="OnPositionChanged"
aria-required="true">
@foreach (var pos in ShelfPositionExtensions.All)
{
<option value="@pos">@pos.GetDisplayName()</option>
}
</select>
</div>
@* Slot Input - Required *@
<div class="input-group slot-input @(slotError != null ? "has-error" : slotTouched && slotError == null ? "has-success" : "")">
<label for="slot-input">
Platz <span class="required-indicator" aria-hidden="true">*</span>
</label>
<input type="number"
id="slot-input"
class="form-control @(slotError != null ? "is-invalid" : slotTouched && slotError == null ? "is-valid" : "")"
min="@StorageLocation.MinSlot"
max="@StorageLocation.MaxSlot"
placeholder="1-9"
@bind="InternalSlot"
@bind:after="OnSlotChanged"
@onfocus="() => slotTouched = true"
@onblur="ValidateSlot"
aria-required="true"
aria-invalid="@(slotError != null ? "true" : "false")"
aria-describedby="@(slotError != null ? "slot-error" : "slot-hint")" />
<span id="slot-hint" class="field-hint">@StorageLocation.MinSlot - @StorageLocation.MaxSlot</span>
@if (slotError != null)
{
<span id="slot-error" class="field-error" role="alert">
<span class="error-icon">?</span> @slotError
</span>
}
else if (slotTouched)
{
<span class="field-success">
<span class="success-icon">?</span>
</span>
}
</div>
</div>
@* Location Code Preview - Prominent Display *@
<div class="code-preview-section">
<div class="code-preview-header">
<span class="preview-label">Stellplatz-Code</span>
</div>
<div class="code-preview-display @(IsFormValid ? "valid" : HasAnyError ? "invalid" : "")">
<span class="code-value">@(IsFormValid ? GeneratedCode : "----")</span>
</div>
<div class="code-preview-description">
@if (IsFormValid)
{
<span class="display-text">@DisplayText</span>
}
else
{
<span class="display-text placeholder">Bitte alle Felder ausfüllen</span>
}
</div>
@* Availability Status *@
@if (ShowAvailability && IsFormValid)
{
<div class="availability-status @GetAvailabilityClass()" role="status" aria-live="polite">
@if (isChecking)
{
<span class="status-spinner"></span>
<span>Verfügbarkeit wird geprüft...</span>
}
else if (isAvailable)
{
<span class="status-icon available-icon">?</span>
<span>Verfügbar</span>
}
else if (locationExists)
{
<span class="status-icon unavailable-icon">?</span>
<span>Dieser Stellplatz ist bereits belegt</span>
}
else
{
<span class="status-icon new-icon">+</span>
<span>Neuer Stellplatz</span>
}
</div>
}
</div>
</div>
@code {
private ElementReference areaSelectRef;
// Internal state for two-way binding
private AreaType InternalArea { get; set; } = AreaType.Lager;
private int InternalRow { get; set; } = 1;
private ShelfPosition InternalPosition { get; set; } = ShelfPosition.LinksOben;
private int InternalSlot { get; set; } = 1;
// Touch/validation state
private bool areaTouched;
private bool rowTouched;
private bool slotTouched;
// Field-level errors
private string? areaError;
private string? rowError;
private string? slotError;
// Availability check state
private bool isChecking;
private bool isAvailable;
private bool locationExists;
private CancellationTokenSource? _checkCts;
[Parameter]
public AreaType SelectedArea { get; set; } = AreaType.Lager;
[Parameter]
public EventCallback<AreaType> SelectedAreaChanged { get; set; }
[Parameter]
public int SelectedRow { get; set; } = 1;
[Parameter]
public EventCallback<int> SelectedRowChanged { get; set; }
[Parameter]
public ShelfPosition SelectedPosition { get; set; } = ShelfPosition.LinksOben;
[Parameter]
public EventCallback<ShelfPosition> SelectedPositionChanged { get; set; }
[Parameter]
public int SelectedSlot { get; set; } = 1;
[Parameter]
public EventCallback<int> SelectedSlotChanged { get; set; }
[Parameter]
public string? LocationCode { get; set; }
[Parameter]
public EventCallback<string> LocationCodeChanged { get; set; }
[Parameter]
public bool ShowAvailability { get; set; } = true;
[Parameter]
public EventCallback<bool> OnAvailabilityChanged { get; set; }
[Parameter]
public EventCallback<bool> OnValidationChanged { get; set; }
private string GeneratedCode => LocationCodeService.GenerateLocationCode(InternalArea, InternalRow, InternalPosition, InternalSlot);
private string DisplayText => $"{InternalArea.GetDisplayName()}, Reihe {InternalRow}, {InternalPosition.GetDisplayName()}, Platz {InternalSlot}";
private bool IsFormValid => areaError == null && rowError == null && slotError == null &&
InternalSlot >= StorageLocation.MinSlot && InternalSlot <= StorageLocation.MaxSlot;
private bool HasAnyError => areaError != null || rowError != null || slotError != null;
protected override void OnInitialized()
{
// Initialize internal state from parameters
InternalArea = SelectedArea;
InternalRow = SelectedRow;
InternalPosition = SelectedPosition;
InternalSlot = SelectedSlot;
}
protected override async Task OnParametersSetAsync()
{
// Sync internal state with parameters
if (InternalArea != SelectedArea) InternalArea = SelectedArea;
if (InternalRow != SelectedRow) InternalRow = SelectedRow;
if (InternalPosition != SelectedPosition) InternalPosition = SelectedPosition;
if (InternalSlot != SelectedSlot) InternalSlot = SelectedSlot;
// Mark as touched if values are set
if (SelectedArea != default) areaTouched = true;
if (SelectedRow > 0) rowTouched = true;
if (SelectedSlot > 0) slotTouched = true;
// Only check availability if we have valid values to check
// Skip the DB call during initial parameter binding to avoid concurrency issues
if (areaTouched && InternalRow > 0 && InternalSlot > 0)
{
await CheckAvailabilityAsync();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Auto-focus the first field
try
{
await areaSelectRef.FocusAsync();
}
catch
{
// Focus may fail in some scenarios, ignore
}
}
}
private string GetAvailabilityClass()
{
if (isChecking) return "checking";
if (isAvailable) return "available";
if (locationExists) return "unavailable";
return "new";
}
private async Task OnAreaChanged()
{
areaTouched = true;
// Clamp row to the new building's max row
if (InternalRow > InternalArea.GetMaxRow())
InternalRow = InternalArea.GetMaxRow();
ValidateArea();
ValidateRow();
await EmitChanges();
}
private async Task OnPositionChanged()
{
await EmitChanges();
}
private async Task OnRowChanged()
{
rowTouched = true;
ValidateRow();
await EmitChanges();
}
private async Task OnSlotChanged()
{
slotTouched = true;
ValidateSlot();
await EmitChanges();
}
private void ValidateArea()
{
areaError = null;
// Area is always valid since it's a select with predefined options
}
private void ValidateRow()
{
rowError = null;
var maxRow = InternalArea.GetMaxRow();
if (InternalRow < StorageLocation.MinRow || InternalRow > maxRow)
{
rowError = $"Reihe muss für {InternalArea.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen";
}
}
private void ValidateSlot()
{
slotError = null;
if (InternalSlot < StorageLocation.MinSlot || InternalSlot > StorageLocation.MaxSlot)
{
slotError = $"Platz muss zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen";
}
}
public bool ValidateAll()
{
areaTouched = true;
rowTouched = true;
slotTouched = true;
ValidateArea();
ValidateRow();
ValidateSlot();
StateHasChanged();
return IsFormValid;
}
private async Task EmitChanges()
{
await SelectedAreaChanged.InvokeAsync(InternalArea);
await SelectedRowChanged.InvokeAsync(InternalRow);
await SelectedPositionChanged.InvokeAsync(InternalPosition);
await SelectedSlotChanged.InvokeAsync(InternalSlot);
await LocationCodeChanged.InvokeAsync(GeneratedCode);
await OnValidationChanged.InvokeAsync(IsFormValid);
await CheckAvailabilityAsync();
}
private async Task CheckAvailabilityAsync()
{
if (!ShowAvailability || !IsFormValid)
{
isAvailable = false;
locationExists = false;
return;
}
// Cancel previous check
_checkCts?.Cancel();
_checkCts = new CancellationTokenSource();
var token = _checkCts.Token;
isChecking = true;
StateHasChanged();
try
{
// Debounce delay
await Task.Delay(300, token);
var code = GeneratedCode;
locationExists = await LocationCodeService.CodeExistsAsync(code);
isAvailable = !locationExists || await LocationCodeService.IsLocationAvailableAsync(code);
await OnAvailabilityChanged.InvokeAsync(isAvailable && !locationExists);
}
catch (OperationCanceledException)
{
// Cancelled, ignore
}
finally
{
isChecking = false;
StateHasChanged();
}
}
public void Reset()
{
InternalArea = AreaType.Lager;
InternalRow = 1;
InternalPosition = ShelfPosition.LinksOben;
InternalSlot = 1;
areaTouched = false;
rowTouched = false;
slotTouched = false;
areaError = null;
rowError = null;
slotError = null;
isAvailable = false;
locationExists = false;
StateHasChanged();
}
public void SetCode(string code)
{
if (LocationCodeService.TryParseLocationCode(code, out var parsed) && parsed != null)
{
InternalArea = parsed.Area;
InternalRow = parsed.Row;
InternalPosition = parsed.Position;
InternalSlot = parsed.SlotNumber;
areaTouched = true;
rowTouched = true;
slotTouched = true;
ValidateAll();
}
}
}