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();
}
}
}
@@ -0,0 +1,372 @@
/* ========================================
Location Code Input Component
Uses global CSS variables from app.css
======================================== */
.location-code-input {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* ========================================
Section Title
======================================== */
.input-section-title {
font-family: var(--font-family-base);
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
margin: 0;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-default);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ========================================
Input Row - Responsive Grid
======================================== */
.input-row {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
@media (min-width: 480px) {
.input-row {
grid-template-columns: 2fr 1fr 1fr;
gap: var(--space-4);
}
}
/* ========================================
Input Group with Validation States
======================================== */
.input-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
position: relative;
}
.input-group label {
font-family: var(--font-family-base);
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-1);
}
/* Required field indicator */
.required-indicator {
color: var(--color-primary);
font-weight: var(--font-weight-bold);
}
/* ========================================
Form Controls with Validation States
======================================== */
.input-group .form-control {
width: 100%;
padding: var(--space-3) var(--space-4);
background: rgba(0, 0, 0, 0.3);
border: 1.5px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-family-base);
font-size: var(--text-base);
box-sizing: border-box;
transition: all var(--duration-fast) var(--ease-default);
}
.input-group .form-control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
background: rgba(0, 0, 0, 0.4);
}
.input-group .form-control::placeholder {
color: var(--text-placeholder);
}
/* Valid state */
.input-group .form-control.is-valid {
border-color: rgba(34, 197, 94, 0.6);
background: rgba(34, 197, 94, 0.05);
}
.input-group .form-control.is-valid:focus {
border-color: var(--color-success);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
}
/* Invalid state */
.input-group .form-control.is-invalid {
border-color: rgba(239, 68, 68, 0.6);
background: rgba(239, 68, 68, 0.05);
}
.input-group .form-control.is-invalid:focus {
border-color: var(--color-error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
}
/* Select styling */
.input-group select.form-control {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' fill-opacity='0.5' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
cursor: pointer;
}
.input-group select.form-control option {
background: #1c1c1e;
color: var(--text-primary);
padding: 10px;
}
/* Number input styling */
.input-group input[type="number"].form-control {
-moz-appearance: textfield;
}
.input-group input[type="number"].form-control::-webkit-outer-spin-button,
.input-group input[type="number"].form-control::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ========================================
Field Hint & Validation Messages
======================================== */
.field-hint {
font-family: var(--font-family-base);
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 2px;
}
.field-error {
display: flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-family-base);
font-size: var(--text-xs);
color: var(--color-error-light);
margin-top: 4px;
animation: slideIn var(--duration-fast) var(--ease-out);
}
.field-success {
display: flex;
align-items: center;
font-family: var(--font-family-base);
font-size: var(--text-xs);
color: var(--color-success);
margin-top: 4px;
animation: slideIn var(--duration-fast) var(--ease-out);
}
.error-icon {
font-size: var(--text-sm);
}
.success-icon {
font-size: var(--text-sm);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Input group states */
.input-group.has-error .form-control {
border-color: rgba(239, 68, 68, 0.6);
}
.input-group.has-success .form-control {
border-color: rgba(34, 197, 94, 0.5);
}
/* ========================================
Code Preview Section - Prominent Display
======================================== */
.code-preview-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
background: linear-gradient(135deg, var(--surface-primary) 0%, rgba(0, 0, 0, 0.4) 100%);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.code-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.preview-label {
font-family: var(--font-family-base);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Main code display */
.code-preview-display {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4) var(--space-6);
background: rgba(0, 0, 0, 0.3);
border: 2px solid var(--border-default);
border-radius: var(--radius-lg);
transition: all var(--duration-normal) var(--ease-default);
}
.code-preview-display.valid {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.08);
}
.code-preview-display.invalid {
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.05);
}
.code-value {
font-family: var(--font-family-heading);
font-size: 2rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.2em;
color: var(--text-primary);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
@media (min-width: 480px) {
.code-value {
font-size: 2.5rem;
}
}
.code-preview-display.valid .code-value {
color: var(--color-success-light);
}
.code-preview-display.invalid .code-value {
color: var(--text-placeholder);
}
/* Description text */
.code-preview-description {
text-align: center;
}
.display-text {
font-family: var(--font-family-base);
font-size: var(--text-base);
color: var(--text-secondary);
}
.display-text.placeholder {
color: var(--text-muted);
font-style: italic;
}
/* ========================================
Availability Status
======================================== */
.availability-status {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-family: var(--font-family-base);
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
transition: all var(--duration-fast) var(--ease-default);
}
.availability-status.checking {
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
color: var(--color-warning);
}
.availability-status.available,
.availability-status.new {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
color: var(--color-success);
}
.availability-status.unavailable {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.25);
color: var(--color-error-light);
}
.status-icon {
font-size: var(--text-lg);
line-height: 1;
}
.available-icon,
.new-icon {
color: var(--color-success);
}
.unavailable-icon {
color: var(--color-error-light);
}
/* Loading spinner for availability check */
.status-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(251, 191, 36, 0.3);
border-top-color: var(--color-warning);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ========================================
Responsive Adjustments
======================================== */
@media (max-width: 479.98px) {
.code-preview-section {
padding: var(--space-4);
}
.code-value {
font-size: 1.75rem;
letter-spacing: 0.15em;
}
.availability-status {
font-size: var(--text-sm);
padding: 0.65rem 0.85rem;
}
}
/* Reduced motion: inherited from app.css (section 17) */
@@ -0,0 +1,188 @@
@using Lagerbestand.Models
@using Lagerbestand.Services
@inject ILocationCodeService LocationCodeService
@if (!string.IsNullOrEmpty(Code))
{
<div class="location-display @SizeClass @AreaClass @(IsOccupied ? "occupied" : "available")">
@if (ShowIcon)
{
<span class="area-icon">@AreaIcon</span>
}
<div class="location-content">
@if (DisplayMode == LocationDisplayMode.Code)
{
<span class="location-code">@Code</span>
}
else if (DisplayMode == LocationDisplayMode.Full)
{
<span class="location-code">@Code</span>
<span class="location-text">@DisplayText</span>
}
else if (DisplayMode == LocationDisplayMode.Text)
{
<span class="location-text">@DisplayText</span>
}
else if (DisplayMode == LocationDisplayMode.Compact)
{
<span class="location-compact">@ShortText</span>
}
</div>
@if (ShowStatus)
{
<span class="status-indicator @(IsOccupied ? "occupied" : "available")" title="@(IsOccupied ? "Belegt" : "Frei")">
@(IsOccupied ? "?" : "?")
</span>
}
</div>
}
else
{
<span class="location-empty">-</span>
}
<style>
.location-display {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-family: inherit;
}
/* Size variants */
.location-display.size-sm {
font-size: 0.8rem;
padding: 0.15rem 0.35rem;
gap: 0.3rem;
}
.location-display.size-md {
font-size: 0.9rem;
}
.location-display.size-lg {
font-size: 1.1rem;
padding: 0.35rem 0.65rem;
}
/* Area color coding */
.location-display.area-lager {
background: rgba(52, 152, 219, 0.1);
border: 1px solid rgba(52, 152, 219, 0.3);
}
.location-display.area-werkstatt {
background: rgba(155, 89, 182, 0.1);
border: 1px solid rgba(155, 89, 182, 0.3);
}
/* Occupancy styling */
.location-display.occupied {
opacity: 0.85;
}
.area-icon {
font-size: 1em;
}
.location-content {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.location-code {
font-family: 'Monaco', 'Consolas', monospace;
font-weight: 700;
letter-spacing: 0.05em;
}
.location-text {
font-size: 0.85em;
color: var(--text-secondary, #666);
}
.location-compact {
font-weight: 500;
}
.status-indicator {
font-size: 0.8em;
}
.status-indicator.available {
color: var(--color-success, #28a745);
}
.status-indicator.occupied {
color: var(--color-error, #dc3545);
}
.location-empty {
color: var(--text-muted, #999);
}
</style>
@code {
[Parameter]
public string? Code { get; set; }
[Parameter]
public LocationDisplayMode DisplayMode { get; set; } = LocationDisplayMode.Full;
[Parameter]
public LocationDisplaySize Size { get; set; } = LocationDisplaySize.Medium;
[Parameter]
public bool ShowIcon { get; set; } = true;
[Parameter]
public bool ShowStatus { get; set; }
[Parameter]
public bool IsOccupied { get; set; }
private ParsedLocationCode? _parsed;
private string SizeClass => Size switch
{
LocationDisplaySize.Small => "size-sm",
LocationDisplaySize.Large => "size-lg",
_ => "size-md"
};
private string AreaClass => _parsed?.Area switch
{
AreaType.Lager => "area-lager",
AreaType.Werkstatt => "area-werkstatt",
_ => ""
};
private string AreaIcon => _parsed?.Area switch
{
AreaType.Lager => "??",
AreaType.Werkstatt => "??",
_ => "??"
};
private string DisplayText => _parsed != null
? $"{_parsed.Area.GetDisplayName()}, Reihe {_parsed.Row}, {_parsed.Position.GetDisplayName()}, Platz {_parsed.SlotNumber}"
: Code ?? "";
private string ShortText => _parsed != null
? $"{_parsed.Area.GetDisplayName()} R{_parsed.Row} {_parsed.Position.GetShortName()} P{_parsed.SlotNumber}"
: Code ?? "";
protected override void OnParametersSet()
{
_parsed = null;
if (!string.IsNullOrEmpty(Code) && LocationCodeService.TryParseLocationCode(Code, out var parsed))
{
_parsed = parsed;
}
}
}
@@ -0,0 +1,37 @@
namespace Lagerbestand.Components.Shared;
/// <summary>
/// Display modes for the LocationDisplay component.
/// </summary>
public enum LocationDisplayMode
{
/// <summary>
/// Just the code "2235"
/// </summary>
Code,
/// <summary>
/// Code + full text "2235" + "Lager, Reihe 2, Rechts oben, Platz 5"
/// </summary>
Full,
/// <summary>
/// Full text only "Lager, Reihe 2, Rechts oben, Platz 5"
/// </summary>
Text,
/// <summary>
/// Compact format "Lager R2 R↑ P5"
/// </summary>
Compact
}
/// <summary>
/// Size variants for the LocationDisplay component.
/// </summary>
public enum LocationDisplaySize
{
Small,
Medium,
Large
}
@@ -0,0 +1,166 @@
@*
SeoMetaTags Component
Renders SEO meta tags dynamically per page via HeadContent
Usage: <SeoMetaTags Title="..." Description="..." Keywords="..." />
*@
<HeadContent>
@* Primary Meta Tags *@
@if (!string.IsNullOrEmpty(Description))
{
<meta name="description" content="@Description" />
}
@if (!string.IsNullOrEmpty(Keywords))
{
<meta name="keywords" content="@Keywords" />
}
@if (!string.IsNullOrEmpty(Robots))
{
<meta name="robots" content="@Robots" />
<meta name="googlebot" content="@Robots" />
}
@if (!string.IsNullOrEmpty(Author))
{
<meta name="author" content="@Author" />
}
@* Canonical URL *@
@if (!string.IsNullOrEmpty(CanonicalUrl))
{
<link rel="canonical" href="@CanonicalUrl" />
<link rel="alternate" hreflang="de-DE" href="@CanonicalUrl" />
<link rel="alternate" hreflang="x-default" href="@CanonicalUrl" />
}
@* Open Graph Meta Tags *@
@if (!string.IsNullOrEmpty(OgTitle ?? Title))
{
<meta property="og:title" content="@(OgTitle ?? Title)" />
}
@if (!string.IsNullOrEmpty(OgDescription ?? Description))
{
<meta property="og:description" content="@(OgDescription ?? Description)" />
}
@if (!string.IsNullOrEmpty(OgType))
{
<meta property="og:type" content="@OgType" />
}
@if (!string.IsNullOrEmpty(OgUrl ?? CanonicalUrl))
{
<meta property="og:url" content="@(OgUrl ?? CanonicalUrl)" />
}
@if (!string.IsNullOrEmpty(OgImage))
{
<meta property="og:image" content="@OgImage" />
@if (OgImageWidth > 0)
{
<meta property="og:image:width" content="@OgImageWidth" />
}
@if (OgImageHeight > 0)
{
<meta property="og:image:height" content="@OgImageHeight" />
}
@if (!string.IsNullOrEmpty(OgImageAlt))
{
<meta property="og:image:alt" content="@OgImageAlt" />
}
<meta property="og:image:type" content="image/png" />
}
@if (!string.IsNullOrEmpty(OgLocale))
{
<meta property="og:locale" content="@OgLocale" />
}
@if (!string.IsNullOrEmpty(OgSiteName))
{
<meta property="og:site_name" content="@OgSiteName" />
}
@* Twitter Card Meta Tags *@
@if (!string.IsNullOrEmpty(TwitterCard))
{
<meta name="twitter:card" content="@TwitterCard" />
}
@if (!string.IsNullOrEmpty(TwitterTitle ?? OgTitle ?? Title))
{
<meta name="twitter:title" content="@(TwitterTitle ?? OgTitle ?? Title)" />
}
@if (!string.IsNullOrEmpty(TwitterDescription ?? OgDescription ?? Description))
{
<meta name="twitter:description" content="@(TwitterDescription ?? OgDescription ?? Description)" />
}
@if (!string.IsNullOrEmpty(TwitterImage ?? OgImage))
{
<meta name="twitter:image" content="@(TwitterImage ?? OgImage)" />
@if (!string.IsNullOrEmpty(TwitterImageAlt ?? OgImageAlt))
{
<meta name="twitter:image:alt" content="@(TwitterImageAlt ?? OgImageAlt)" />
}
}
@* Article-specific metadata (for blog posts, news articles) *@
@if (!string.IsNullOrEmpty(ArticlePublishedTime))
{
<meta property="article:published_time" content="@ArticlePublishedTime" />
}
@if (!string.IsNullOrEmpty(ArticleModifiedTime))
{
<meta property="article:modified_time" content="@ArticleModifiedTime" />
}
@if (!string.IsNullOrEmpty(ArticleAuthor))
{
<meta property="article:author" content="@ArticleAuthor" />
}
@* Structured Data (JSON-LD) *@
@if (!string.IsNullOrEmpty(JsonLd))
{
<script type="application/ld+json">@((MarkupString)JsonLd)</script>
}
</HeadContent>
@code {
/// <summary>Page title (used as fallback for OG/Twitter)</summary>
[Parameter] public string? Title { get; set; }
/// <summary>Meta description for search engines (max 160 chars recommended)</summary>
[Parameter] public string? Description { get; set; }
/// <summary>Comma-separated keywords</summary>
[Parameter] public string? Keywords { get; set; }
/// <summary>Robots directive (e.g., "index, follow" or "noindex, nofollow")</summary>
[Parameter] public string? Robots { get; set; } = "index, follow";
/// <summary>Author name</summary>
[Parameter] public string? Author { get; set; }
/// <summary>Canonical URL for the page</summary>
[Parameter] public string? CanonicalUrl { get; set; }
// Open Graph parameters
[Parameter] public string? OgTitle { get; set; }
[Parameter] public string? OgDescription { get; set; }
[Parameter] public string? OgType { get; set; } = "website";
[Parameter] public string? OgUrl { get; set; }
[Parameter] public string? OgImage { get; set; }
[Parameter] public int OgImageWidth { get; set; } = 1200;
[Parameter] public int OgImageHeight { get; set; } = 630;
[Parameter] public string? OgImageAlt { get; set; }
[Parameter] public string? OgLocale { get; set; } = "de_DE";
[Parameter] public string? OgSiteName { get; set; } = "Reifen Späth";
// Twitter Card parameters
[Parameter] public string? TwitterCard { get; set; } = "summary_large_image";
[Parameter] public string? TwitterTitle { get; set; }
[Parameter] public string? TwitterDescription { get; set; }
[Parameter] public string? TwitterImage { get; set; }
[Parameter] public string? TwitterImageAlt { get; set; }
// Article metadata (for content pages)
[Parameter] public string? ArticlePublishedTime { get; set; }
[Parameter] public string? ArticleModifiedTime { get; set; }
[Parameter] public string? ArticleAuthor { get; set; }
/// <summary>JSON-LD structured data string</summary>
[Parameter] public string? JsonLd { get; set; }
}
@@ -0,0 +1,45 @@
@* Masked input for tire sizes in the format ###/## R## (e.g. 225/45 R17).
The user only types digits; separators are inserted automatically. *@
<input type="text"
class="form-control @CssClass"
inputmode="numeric"
maxlength="10"
placeholder="@Placeholder"
value="@Value"
@oninput="OnInput" />
@code {
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[Parameter] public string Placeholder { get; set; } = "z.B. 225/45 R17";
[Parameter] public string? CssClass { get; set; }
private async Task OnInput(ChangeEventArgs e)
{
var raw = e.Value?.ToString() ?? string.Empty;
var formatted = FormatTireSize(raw);
Value = formatted;
await ValueChanged.InvokeAsync(formatted);
}
/// <summary>
/// Formats digit input into the tire size mask ###/## R##.
/// Examples: "2254517" -> "225/45 R17", "22545" -> "225/45", "225" -> "225"
/// </summary>
private static string FormatTireSize(string input)
{
var digits = new string(input.Where(char.IsDigit).Take(7).ToArray());
if (digits.Length == 0)
return string.Empty;
if (digits.Length <= 3)
return digits;
if (digits.Length <= 5)
return $"{digits[..3]}/{digits[3..]}";
return $"{digits[..3]}/{digits[3..5]} R{digits[5..]}";
}
}
@@ -0,0 +1,43 @@
using Microsoft.JSInterop;
namespace Lagerbestand.Components.Shared;
/// <summary>
/// Helper that wires a page's reload method to the browser's
/// <c>visibilitychange</c> event via <c>appInterop.registerVisibilityRefresh</c>.
/// The caller owns the <see cref="DotNetObjectReference"/> passed in.
/// </summary>
public static class VisibilityRefresh
{
/// <summary>
/// Registers a .NET callback invoked when the browser tab becomes
/// visible again after being hidden for at least <paramref name="thresholdSeconds"/>.
/// </summary>
public static async Task RegisterAsync<T>(
IJSRuntime js,
DotNetObjectReference<T> dotNetRef,
string methodName,
int thresholdSeconds = 120) where T : class
{
try
{
await js.InvokeVoidAsync("appInterop.registerVisibilityRefresh", dotNetRef, methodName, thresholdSeconds);
}
catch
{
// Circuit may be mid-reconnect — non-fatal.
}
}
/// <summary>
/// Unregisters the visibility callback. Safe to call multiple times.
/// </summary>
public static async Task UnregisterAsync(IJSRuntime js)
{
try
{
await js.InvokeVoidAsync("appInterop.unregisterVisibilityRefresh");
}
catch { /* ignore */ }
}
}