Files

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