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,653 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject UserSessionService UserSession
@inject IAuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@if (IsPublicPage())
{
<main id="main-content">
@Body
</main>
}
else
{
@switch (state)
{
case AuthState.Loading:
<div class="auth-loading" role="status" aria-label="Lade Anwendung" aria-live="polite">
<div class="spinner-border" aria-hidden="true"></div>
<p>Lade...</p>
</div>
break;
case AuthState.Authenticated:
<div class="app-layout">
<header class="app-header" role="banner">
<div class="header-container">
<a href="/" class="header-brand" aria-label="Reifen Späth - Zur Startseite">
<span class="brand-name">REIFEN SPÄTH</span>
<span class="brand-sub">Lagerverwaltung</span>
</a>
<nav class="header-nav" role="navigation" aria-label="Hauptnavigation">
@* Worker and Admin see: Lager, Einlagern, Abholen *@
@if (UserSession.IsWorkerOrAdmin)
{
<a href="/tire-storage" class="nav-item @(IsCurrentPage("/tire-storage") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-storage") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Lager</span>
</a>
<a href="/tire-checkin" class="nav-item @(IsCurrentPage("/tire-checkin") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-checkin") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M12 5v14M5 12h14"></path>
</svg>
<span>Einlagern</span>
</a>
<a href="/tire-checkout" class="nav-item @(IsCurrentPage("/tire-checkout") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-checkout") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M5 12h14M12 5l7 7-7 7"></path>
</svg>
<span>Abholen</span>
</a>
<a href="/dashboard" class="nav-item @(IsCurrentPage("/dashboard") ? "active" : "")" aria-current="@(IsCurrentPage("/dashboard") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Dashboard</span>
</a>
}
@* Admin only sees: Kunden, Plätze, Benutzer, Status *@
@if (UserSession.IsAdmin)
{
<a href="/customers" class="nav-item @(IsCurrentPage("/customers") ? "active" : "")" aria-current="@(IsCurrentPage("/customers") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<span>Kunden</span>
</a>
<a href="/storage-locations" class="nav-item @(IsCurrentPage("/storage-locations") ? "active" : "")" aria-current="@(IsCurrentPage("/storage-locations") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Plätze</span>
</a>
<a href="/users" class="nav-item @(IsCurrentPage("/users") ? "active" : "")" aria-current="@(IsCurrentPage("/users") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span>Benutzer</span>
</a>
<a href="/status" class="nav-item @(IsCurrentPage("/status") ? "active" : "")" aria-current="@(IsCurrentPage("/status") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span>Status</span>
</a>
}
</nav>
<div class="header-user">
<div class="user-dropdown">
<button type="button"
class="user-dropdown-toggle"
@onclick="ToggleUserMenu"
@onclick:stopPropagation
aria-expanded="@showUserMenu"
aria-haspopup="menu"
aria-controls="user-menu"
aria-label="Benutzermenü öffnen">
<div class="user-info">
<span class="user-name">@UserSession.DisplayName</span>
<span class="user-role">@UserSession.RoleName</span>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="@(showUserMenu ? "rotated" : "")" aria-hidden="true" focusable="false">
<path d="M6 9l6 6 6-6"></path>
</svg>
</button>
@if (showUserMenu)
{
<div class="user-dropdown-menu" id="user-menu" role="menu" aria-label="Benutzeroptionen">
<button type="button" class="dropdown-item" @onclick="OpenPasswordModal" role="menuitem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
Passwort ändern
</button>
<div class="dropdown-divider" role="separator"></div>
<button type="button" class="dropdown-item dropdown-item-danger" @onclick="OpenLogoutModal" role="menuitem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Abmelden
</button>
</div>
}
</div>
</div>
<button type="button"
class="mobile-menu-toggle"
@onclick="ToggleMobileMenu"
aria-expanded="@showMobileMenu"
aria-controls="mobile-menu"
aria-label="@(showMobileMenu ? "Menü schließen" : "Menü öffnen")">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
@if (showMobileMenu)
{
<nav class="mobile-menu" id="mobile-menu" role="navigation" aria-label="Mobile Navigation">
@* Worker and Admin see: Lager, Einlagern, Abholen, Dashboard *@
@if (UserSession.IsWorkerOrAdmin)
{
<a href="/tire-storage" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📊</span> Lager</a>
<a href="/tire-checkin" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">➕</span> Einlagern</a>
<a href="/tire-checkout" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📤</span> Abholen</a>
<a href="/dashboard" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📋</span> Dashboard</a>
}
@* Admin only sees: Kunden, Plätze, Benutzer, Status *@
@if (UserSession.IsAdmin)
{
<a href="/customers" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">👥</span> Kunden</a>
<a href="/storage-locations" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📍</span> Lagerplätze</a>
<a href="/users" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">👤</span> Benutzer</a>
<a href="/status" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📈</span> Status</a>
}
<div class="mobile-user-info">
<span>@UserSession.DisplayName</span>
<div class="mobile-user-actions">
<button type="button" class="btn-password-mobile" @onclick="OpenPasswordModal" title="Passwort ändern" aria-label="Passwort ändern">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</button>
<button type="button" class="btn-logout-mobile" @onclick="OpenLogoutModal">Abmelden</button>
</div>
</div>
</nav>
}
</header>
<main id="main-content" class="app-main" role="main">
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent Context="ex">
<div class="blazor-error-boundary" role="alert">
<p><strong>Ein Fehler ist aufgetreten.</strong></p>
<p>Die Seite kann m&ouml;glicherweise nicht korrekt angezeigt werden. Bitte laden Sie die Anwendung neu.</p>
<button type="button" class="btn btn-primary" @onclick="ReloadPage">Neu laden</button>
</div>
</ErrorContent>
</ErrorBoundary>
</main>
</div>
break;
case AuthState.NotAuthenticated:
<main class="auth-redirect" role="main">
<div class="auth-icon" aria-hidden="true">🔒</div>
<h1>Anmeldung erforderlich</h1>
<p>Sie müssen angemeldet sein, um auf diese Seite zuzugreifen.</p>
<a href="/login" class="btn btn-primary">Zum Login</a>
</main>
break;
}
}
@* Logout Modal *@
@if (showLogoutModal)
{
<div class="global-modal-backdrop" @onclick="CloseAllModals" aria-hidden="true"></div>
<div class="global-modal-container" @onclick="CloseAllModals" role="dialog" aria-modal="true" aria-labelledby="logout-title" aria-describedby="logout-desc">
<div class="global-modal-dialog" @onclick:stopPropagation>
<div class="global-modal-header">
<h2 id="logout-title">Abmelden</h2>
<button type="button" class="global-modal-close" @onclick="CloseAllModals" aria-label="Dialog schließen">
<span aria-hidden="true">✕</span>
</button>
</div>
<div class="global-modal-body">
<div class="global-modal-icon global-modal-icon-warning" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" focusable="false">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</div>
<p id="logout-desc">Möchten Sie sich wirklich abmelden?</p>
<p class="text-muted">Sie werden zur Startseite weitergeleitet.</p>
</div>
<div class="global-modal-footer">
<button type="button" class="global-modal-btn global-modal-btn-secondary" @onclick="CloseAllModals">Abbrechen</button>
<button type="button" class="global-modal-btn global-modal-btn-danger" @onclick="ExecuteLogout" disabled="@isLoggingOut" aria-busy="@isLoggingOut">
@if (isLoggingOut)
{
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden">Abmeldung läuft...</span>
}
else
{
<span>Abmelden</span>
}
</button>
</div>
</div>
</div>
}
@* Password Change Modal *@
@if (showPasswordModal)
{
<div class="global-modal-backdrop" @onclick="CloseAllModals" aria-hidden="true"></div>
<div class="global-modal-container" @onclick="CloseAllModals" role="dialog" aria-modal="true" aria-labelledby="password-title">
<div class="global-modal-dialog global-modal-dialog-lg" @onclick:stopPropagation>
<div class="global-modal-header">
<h2 id="password-title">Passwort ändern</h2>
<button type="button" class="global-modal-close" @onclick="CloseAllModals" aria-label="Dialog schließen">
<span aria-hidden="true">✕</span>
</button>
</div>
<div class="global-modal-body">
@if (!string.IsNullOrEmpty(passwordError))
{
<div class="global-modal-alert global-modal-alert-danger" role="alert" aria-live="assertive">@passwordError</div>
}
@if (!string.IsNullOrEmpty(passwordSuccess))
{
<div class="global-modal-alert global-modal-alert-success" role="status" aria-live="polite">@passwordSuccess</div>
}
<div class="global-modal-form-group">
<label class="global-modal-form-label" for="current-password">Aktuelles Passwort</label>
<input type="password"
id="current-password"
class="global-modal-form-control"
@bind="currentPassword"
@bind:event="oninput"
placeholder="Aktuelles Passwort eingeben"
disabled="@isChangingPassword"
autocomplete="current-password"
required
aria-required="true" />
</div>
<div class="global-modal-form-group">
<label class="global-modal-form-label" for="new-password">Neues Passwort</label>
<input type="password"
id="new-password"
class="global-modal-form-control"
@bind="newPassword"
@bind:event="oninput"
placeholder="Neues Passwort eingeben"
disabled="@isChangingPassword"
autocomplete="new-password"
required
aria-required="true"
aria-describedby="password-hint" />
<small id="password-hint" class="global-modal-form-hint">Mindestens 4 Zeichen</small>
</div>
<div class="global-modal-form-group">
<label class="global-modal-form-label" for="confirm-password">Neues Passwort bestätigen</label>
<input type="password"
id="confirm-password"
class="global-modal-form-control"
@bind="confirmPassword"
@bind:event="oninput"
placeholder="Neues Passwort wiederholen"
disabled="@isChangingPassword"
autocomplete="new-password"
required
aria-required="true" />
</div>
</div>
<div class="global-modal-footer">
<button type="button" class="global-modal-btn global-modal-btn-secondary" @onclick="CloseAllModals" disabled="@isChangingPassword">Abbrechen</button>
<button type="button" class="global-modal-btn global-modal-btn-primary" @onclick="ChangePassword" disabled="@(isChangingPassword || !CanChangePassword)" aria-busy="@isChangingPassword">
@if (isChangingPassword)
{
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden">Passwort wird geändert...</span>
}
else
{
<span>Passwort ändern</span>
}
</button>
</div>
</div>
</div>
}
@code {
private enum AuthState { Loading, Authenticated, NotAuthenticated }
private AuthState state = AuthState.Loading;
private bool showMobileMenu;
private bool showUserMenu;
private bool showLogoutModal;
private bool showPasswordModal;
private bool isLoggingOut;
// Password change form
private string currentPassword = "";
private string newPassword = "";
private string confirmPassword = "";
private string? passwordError;
private string? passwordSuccess;
private bool isChangingPassword;
private bool CanChangePassword =>
!string.IsNullOrWhiteSpace(currentPassword) &&
!string.IsNullOrWhiteSpace(newPassword) &&
newPassword.Length >= 4 &&
newPassword == confirmPassword;
private ErrorBoundary? errorBoundary;
private DotNetObjectReference<MainLayout>? dotNetRef;
private bool userMenuClickOutsideRegistered;
private bool lastAuthMarker;
protected override void OnInitialized()
{
UserSession.OnChange += HandleUserChanged;
if (UserSession.IsAuthenticated)
{
state = AuthState.Authenticated;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetRef = DotNetObjectReference.Create(this);
if (!IsPublicPage() && state == AuthState.Loading)
{
await RestoreSessionAsync();
}
}
// Keep <body data-authenticated> in sync so app-interop.js can
// auto-start/stop the idle watcher.
var isAuthed = state == AuthState.Authenticated;
if (isAuthed != lastAuthMarker)
{
lastAuthMarker = isAuthed;
try
{
await JS.InvokeVoidAsync("document.body.setAttribute", "data-authenticated", isAuthed ? "true" : "false");
if (!isAuthed)
{
await JS.InvokeVoidAsync("appInterop.stopIdleWatcher");
}
}
catch { /* circuit may be mid-reconnect */ }
}
// Register / unregister click-outside for the user dropdown.
try
{
if (showUserMenu && !userMenuClickOutsideRegistered && dotNetRef != null)
{
await JS.InvokeVoidAsync("appInterop.registerClickOutside", dotNetRef, nameof(CloseUserMenuFromJs), ".user-dropdown");
userMenuClickOutsideRegistered = true;
}
else if (!showUserMenu && userMenuClickOutsideRegistered)
{
await JS.InvokeVoidAsync("appInterop.unregisterClickOutside");
userMenuClickOutsideRegistered = false;
}
}
catch { /* non-fatal */ }
}
[JSInvokable]
public Task CloseUserMenuFromJs()
{
if (showUserMenu)
{
showUserMenu = false;
StateHasChanged();
}
return Task.CompletedTask;
}
private void ReloadPage()
{
errorBoundary?.Recover();
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private async Task RestoreSessionAsync()
{
try
{
var userId = await JS.InvokeAsync<string?>("localStorage.getItem", "userId");
if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid))
{
var user = await AuthService.GetUserByIdAsync(guid);
if (user != null && user.IsActive)
{
UserSession.SetUser(user);
state = AuthState.Authenticated;
await InvokeAsync(StateHasChanged);
return;
}
// Clear invalid session
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
}
}
catch (Exception)
{
// Continue to not authenticated state
}
state = AuthState.NotAuthenticated;
await InvokeAsync(StateHasChanged);
}
private void HandleUserChanged()
{
state = UserSession.IsAuthenticated ? AuthState.Authenticated : AuthState.NotAuthenticated;
InvokeAsync(StateHasChanged);
}
private bool IsPublicPage()
{
var uri = Navigation.ToBaseRelativePath(Navigation.Uri).ToLowerInvariant();
return string.IsNullOrEmpty(uri) ||
uri == "/" ||
uri.StartsWith("login") ||
uri.StartsWith("impressum") ||
uri.StartsWith("datenschutz");
}
private bool IsCurrentPage(string path)
{
var uri = "/" + Navigation.ToBaseRelativePath(Navigation.Uri);
return uri.Equals(path, StringComparison.OrdinalIgnoreCase);
}
private void ToggleMobileMenu()
{
showMobileMenu = !showMobileMenu;
showUserMenu = false;
}
private void CloseMobileMenu() => showMobileMenu = false;
private void ToggleUserMenu() => showUserMenu = !showUserMenu;
private void OpenLogoutModal()
{
CloseMenus();
showLogoutModal = true;
}
private void OpenPasswordModal()
{
CloseMenus();
ResetPasswordForm();
showPasswordModal = true;
}
private void CloseMenus()
{
showUserMenu = false;
showMobileMenu = false;
}
private void CloseAllModals()
{
showLogoutModal = false;
showPasswordModal = false;
showUserMenu = false;
showMobileMenu = false;
ResetPasswordForm();
}
private void ResetPasswordForm()
{
currentPassword = "";
newPassword = "";
confirmPassword = "";
passwordError = null;
passwordSuccess = null;
isChangingPassword = false;
}
private async Task ChangePassword()
{
passwordError = null;
passwordSuccess = null;
if (string.IsNullOrWhiteSpace(currentPassword))
{
passwordError = "Bitte geben Sie Ihr aktuelles Passwort ein.";
return;
}
var validation = AuthService.ValidatePassword(newPassword);
if (!validation.IsValid)
{
passwordError = validation.ErrorMessage;
return;
}
if (newPassword != confirmPassword)
{
passwordError = "Die Passwörter stimmen nicht überein.";
return;
}
isChangingPassword = true;
StateHasChanged();
try
{
var user = UserSession.CurrentUser;
if (user == null)
{
passwordError = "Benutzer nicht gefunden.";
return;
}
var verifiedUser = await AuthService.AuthenticateAsync(user.Username, currentPassword);
if (verifiedUser == null)
{
passwordError = "Das aktuelle Passwort ist falsch.";
await PlayFeedback("error");
return;
}
var success = await AuthService.ChangePasswordAsync(user.Id, newPassword);
if (success)
{
passwordSuccess = "Passwort wurde erfolgreich geändert!";
currentPassword = "";
newPassword = "";
confirmPassword = "";
await PlayFeedback("success");
StateHasChanged();
await Task.Delay(1500);
CloseAllModals();
}
else
{
passwordError = "Fehler beim Ändern des Passworts.";
await PlayFeedback("error");
}
}
catch (Exception ex)
{
passwordError = $"Fehler: {ex.Message}";
await PlayFeedback("error");
}
finally
{
isChangingPassword = false;
StateHasChanged();
}
}
private async Task ExecuteLogout()
{
isLoggingOut = true;
StateHasChanged();
try
{
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
}
catch { }
UserSession.ClearUser();
showLogoutModal = false;
Navigation.NavigateTo("/", forceLoad: true);
}
private async Task PlayFeedback(string type)
{
try
{
await JS.InvokeVoidAsync("qrScanner.playSound", type);
}
catch { }
}
public void Dispose()
{
UserSession.OnChange -= HandleUserChanged;
dotNetRef?.Dispose();
}
}