452 lines
15 KiB
Plaintext
452 lines
15 KiB
Plaintext
@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();
|
||
}
|
||
}
|
||
}
|