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

+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.1",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
@* Primary SEO Meta Tags *@
<meta name="description" content="Reifen Späth Kfz-Meisterbetrieb in Langenmosen - Ihr Spezialist für Premium Reifen, Felgen, Reifenwechsel und professionelle Reifeneinlagerung in der Region." />
<meta name="keywords" content="Reifen, Felgen, Kfz-Meisterbetrieb, Reifenwechsel, Reifeneinlagerung, Langenmosen, Bayern, Alufelgen, Winterreifen, Sommerreifen" />
<meta name="author" content="Reifen Späth - Daniel Späth" />
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="bingbot" content="index, follow" />
@* Theme & Mobile Meta Tags *@
<meta name="theme-color" content="#dc2626" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#dc2626" media="(prefers-color-scheme: dark)" />
<meta name="color-scheme" content="dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Reifen Späth" />
<meta name="application-name" content="Reifen Späth" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-TileColor" content="#dc2626" />
<meta name="msapplication-config" content="none" />
@* Open Graph Default Meta Tags *@
<meta property="og:site_name" content="Reifen Späth Kfz-Meisterbetrieb" />
<meta property="og:locale" content="de_DE" />
<meta property="og:type" content="website" />
@* Twitter Card Defaults *@
<meta name="twitter:card" content="summary_large_image" />
@* Geo Meta Tags for Local SEO *@
<meta name="geo.region" content="DE-BY" />
<meta name="geo.placename" content="Langenmosen" />
<meta name="geo.position" content="48.6;11.15" />
<meta name="ICBM" content="48.6, 11.15" />
<base href="/" />
@* Preconnect for critical resources (performance) *@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@* DNS Prefetch for external resources *@
<link rel="dns-prefetch" href="https://analytics.spaeth-bayern.de" />
@* Preload critical CSS for faster LCP *@
<link rel="preload" href="app.css?v=@CacheVersion" as="style" />
@* Fonts with display=swap for better CLS *@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet" />
@* Stylesheets *@
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css?v=@CacheVersion" />
<link rel="stylesheet" href="css/app-interop.css?v=@CacheVersion" />
<link rel="stylesheet" href="Lagerbestand.styles.css?v=@CacheVersion" />
@* Favicon & Icons for all platforms - cache busted *@
<link rel="icon" href="favicon.png?v=2" type="image/png" />
<link rel="shortcut icon" href="favicon.png?v=2" type="image/png" />
<link rel="apple-touch-icon" href="favicon.png?v=2" sizes="180x180" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon.png?v=2" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon.png?v=2" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon.png?v=2" />
<link rel="mask-icon" href="favicon.png?v=2" color="#dc2626" />
<meta name="msapplication-TileImage" content="favicon.png?v=2" />
@* Structured Data - Organization Schema *@
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "AutoRepair",
"@@id": "https://www.reifenspaeth.de/#organization",
"name": "Reifen Späth Kfz-Meisterbetrieb",
"alternateName": "Reifen Späth",
"url": "https://www.reifenspaeth.de",
"logo": {
"@@type": "ImageObject",
"url": "https://www.reifenspaeth.de/favicon.png",
"width": 512,
"height": 512
},
"image": "https://www.reifenspaeth.de/favicon.png",
"description": "Kfz-Meisterbetrieb - Ihr Spezialist für Premium Reifen, Felgen und professionelle Reifeneinlagerung in Langenmosen.",
"telephone": "+4915568569616",
"email": "info@reifenspaeth.de",
"address": {
"@@type": "PostalAddress",
"streetAddress": "Sandizeller Str. 10C",
"addressLocality": "Langenmosen",
"postalCode": "86571",
"addressCountry": "DE",
"addressRegion": "Bayern"
},
"geo": {
"@@type": "GeoCoordinates",
"latitude": 48.6,
"longitude": 11.15
},
"openingHoursSpecification": [
{
"@@type": "OpeningHoursSpecification",
"dayOfWeek": "Friday",
"opens": "15:00",
"closes": "18:00"
},
{
"@@type": "OpeningHoursSpecification",
"dayOfWeek": "Saturday",
"opens": "09:00",
"closes": "16:00"
}
],
"priceRange": "€€",
"currenciesAccepted": "EUR",
"paymentAccepted": "Cash, EC Card",
"areaServed": [
"Langenmosen",
"Neuburg an der Donau",
"Schrobenhausen",
"Ingolstadt",
"Aichach-Friedberg"
],
"sameAs": [],
"hasOfferCatalog": {
"@@type": "OfferCatalog",
"name": "Reifen & Felgen Services",
"itemListElement": [
{
"@@type": "Offer",
"itemOffered": {
"@@type": "Service",
"name": "Reifenwechsel",
"description": "Professioneller Wechsel Ihrer Sommerreifen und Winterreifen mit Auswuchten und Druckkontrolle"
}
},
{
"@@type": "Offer",
"itemOffered": {
"@@type": "Service",
"name": "Reifeneinlagerung",
"description": "Professionelle Einlagerung Ihrer Reifen - ganzjährig verfügbar"
}
},
{
"@@type": "Offer",
"itemOffered": {
"@@type": "Service",
"name": "Felgen-Service",
"description": "Alufelgen, Stahlfelgen und Spezialfelgen - Verkauf, Montage und Reparatur"
}
}
]
}
}
</script>
@* Structured Data - Website Schema for sitelinks searchbox *@
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "WebSite",
"@@id": "https://www.reifenspaeth.de/#website",
"url": "https://www.reifenspaeth.de",
"name": "Reifen Späth",
"description": "Kfz-Meisterbetrieb für Premium Reifen und Felgen in Langenmosen",
"publisher": {
"@@id": "https://www.reifenspaeth.de/#organization"
},
"inLanguage": "de-DE"
}
</script>
@* Analytics - defer for non-blocking *@
<script defer src="https://analytics.spaeth-bayern.de/script.js" data-website-id="de97bee6-7355-4fe5-9b52-ddb4cf2c708c"></script>
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
@* Skip to main content link for accessibility *@
<a href="#main-content" class="skip-link visually-hidden-focusable">Zum Hauptinhalt springen</a>
<Routes @rendermode="InteractiveServer" />
@* Custom Blazor Server reconnect overlay (replaces default) *@
<div id="components-reconnect-modal" role="alertdialog" aria-modal="true" aria-labelledby="reconnect-title" aria-live="assertive">
<div class="reconnect-dialog">
<div class="reconnect-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.22-8.56"></path>
<polyline points="21 3 21 9 15 9"></polyline>
</svg>
</div>
<h2 id="reconnect-title">Verbindung wird wiederhergestellt</h2>
<p class="reconnect-text-show">
Die Verbindung zum Server wurde kurzzeitig unterbrochen.
Ihre Eingaben bleiben erhalten &ndash; bitte einen Moment warten&hellip;
</p>
<p class="reconnect-text-failed">
Die Verbindung zum Server konnte nicht wiederhergestellt werden.
Bitte laden Sie die Seite neu.
</p>
<p class="reconnect-text-rejected">
Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu,
um weiterzuarbeiten.
</p>
<div class="reconnect-actions-failed">
<button type="button" class="reconnect-retry">Erneut versuchen</button>
<button type="button" class="reconnect-reload">Seite neu laden</button>
</div>
</div>
</div>
@* Idle-lock overlay (JS-driven via appInterop.startIdleWatcher) *@
<div id="app-idle-overlay" role="alertdialog" aria-modal="true" aria-labelledby="idle-title" aria-hidden="true">
<div class="app-idle-dialog">
<div class="app-idle-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</div>
<h2 id="idle-title">Sitzung pausiert</h2>
<p>Ihre Sitzung ist aufgrund l&auml;ngerer Inaktivit&auml;t pausiert.<br />
Alle Eingaben bleiben erhalten.</p>
<button type="button" class="app-idle-resume">Weiter arbeiten</button>
</div>
</div>
@* Blazor Framework - defer loading *@
<script src="_framework/blazor.web.js" autostart="true"></script>
<script src="js/app-interop.js?v=@CacheVersion" defer></script>
</body>
</html>
@code {
private static readonly string CacheVersion = DateTime.UtcNow.Ticks.ToString();
}
@@ -0,0 +1,3 @@
@inherits LayoutComponentBase
@Body
@@ -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();
}
}
@@ -0,0 +1,492 @@
/* ========================================
MainLayout - Apple HIG Admin Interface
Uses global CSS variables from app.css
======================================== */
.page {
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
/* ========================================
Auth States
======================================== */
.auth-icon {
font-family: var(--font-family-emoji);
font-size: 3rem;
line-height: 1;
margin-bottom: var(--space-4);
}
/* ========================================
App Layout
======================================== */
.app-layout {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-bg);
}
/* ========================================
App Header - Responsive
======================================== */
.app-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: rgba(0, 0, 0, 0.85);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--border-default);
}
.header-container {
max-width: 1600px;
margin: 0 auto;
padding: 0 var(--space-2);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
@media (min-width: 480px) {
.header-container {
padding: 0 var(--space-4);
gap: var(--space-4);
}
}
@media (min-width: 768px) {
.header-container {
height: 60px;
gap: var(--space-6);
}
}
@media (min-width: 1024px) {
.header-container {
padding: 0 var(--space-6);
height: 64px;
}
}
.header-brand {
display: flex;
flex-direction: column;
text-decoration: none;
flex-shrink: 0;
transition: opacity var(--duration-fast) var(--ease-out);
}
.header-brand:hover {
opacity: 0.9;
}
.header-brand:active {
opacity: 0.7;
}
.header-brand .brand-name {
font-family: var(--font-family-heading);
font-weight: var(--font-weight-bold);
font-size: clamp(0.9rem, 2.5vw, 1.2rem);
color: var(--text-primary);
line-height: var(--line-height-tight);
}
.header-brand .brand-sub {
font-family: var(--font-family-base);
font-size: clamp(0.6rem, 1.5vw, 0.7rem);
color: var(--text-secondary);
}
/* ========================================
Header Navigation - Responsive
======================================== */
.header-nav {
display: none;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.06);
padding: 4px;
border-radius: var(--radius-lg);
width: fit-content;
}
@media (min-width: 768px) {
.header-nav {
display: flex;
}
}
@media (min-width: 1200px) {
.header-nav {
gap: 6px;
}
}
.nav-item {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 10px;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--text-secondary);
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
font-size: 0.8rem;
transition: all var(--duration-fast) var(--ease-out);
white-space: nowrap;
}
@media (min-width: 768px) {
.nav-item {
padding: 8px 12px;
font-size: 0.85rem;
}
}
@media (min-width: 1024px) {
.nav-item {
padding: 10px 16px;
gap: 6px;
font-size: 0.9rem;
}
}
@media (min-width: 1200px) {
.nav-item {
padding: 10px 18px;
font-size: 0.95rem;
}
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.08);
text-decoration: none;
}
.nav-item:active {
transform: scale(0.96);
}
.nav-item.active {
color: var(--text-primary);
background: var(--surface-secondary);
box-shadow: var(--shadow-sm);
}
.nav-item svg {
flex-shrink: 0;
width: 14px;
height: 14px;
}
@media (min-width: 1024px) {
.nav-item svg {
width: 16px;
height: 16px;
}
}
/* Hide nav text on smaller tablets */
@media (min-width: 768px) and (max-width: 899px) {
.nav-item span {
display: none;
}
.nav-item {
padding: 10px 12px;
}
.nav-item svg {
width: 18px;
height: 18px;
}
}
/* ========================================
User Dropdown - Responsive
======================================== */
.header-user {
display: none;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
@media (min-width: 768px) {
.header-user {
display: flex;
}
}
.user-dropdown {
position: relative;
}
.user-dropdown-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.06);
border: 0.5px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
}
.user-dropdown-toggle:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.user-dropdown-toggle:active {
transform: scale(0.97);
}
.user-dropdown-toggle .user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
}
.user-dropdown-toggle .user-name {
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
font-size: 0.9rem;
line-height: var(--line-height-tight);
}
.user-dropdown-toggle .user-role {
font-family: var(--font-family-base);
font-size: 0.7rem;
color: var(--text-secondary);
background: none;
padding: 0;
}
.user-dropdown-toggle svg {
color: var(--text-secondary);
width: 14px;
height: 14px;
transition: transform var(--duration-fast) var(--ease-out);
}
.user-dropdown-toggle svg.rotated {
transform: rotate(180deg);
}
.user-dropdown-menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 220px;
background: var(--surface-primary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
z-index: var(--z-dropdown);
overflow: hidden;
animation: dropdownIn var(--duration-normal) var(--ease-spring);
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 18px;
background: transparent;
border: none;
color: var(--text-primary);
font-family: var(--font-family-base);
font-size: 1rem;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
text-align: left;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.dropdown-item:active {
background: rgba(255, 255, 255, 0.1);
}
.dropdown-item svg {
width: 18px;
height: 18px;
color: var(--text-secondary);
}
.dropdown-item-danger {
color: var(--color-error-light);
background: rgba(220, 38, 38, 0.08);
border-top: 0.5px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.dropdown-item-danger:hover {
background: rgba(220, 38, 38, 0.15);
}
.dropdown-item-danger svg {
color: var(--color-error-light);
}
.dropdown-divider {
height: 0.5px;
background: var(--border-default);
margin: 6px 0;
}
/* ========================================
Mobile Menu - Enhanced Responsive
======================================== */
.mobile-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
width: var(--touch-target);
height: var(--touch-target);
background: transparent;
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
-webkit-tap-highlight-color: transparent;
}
.mobile-menu-toggle:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.1);
}
@media (min-width: 768px) {
.mobile-menu-toggle {
display: none;
}
}
.mobile-menu {
background: rgba(0, 0, 0, 0.98);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: var(--space-4);
border-top: 0.5px solid var(--border-default);
animation: mobileMenuIn var(--duration-normal) var(--ease-out);
max-height: calc(100vh - 60px);
max-height: calc(100dvh - 60px);
overflow-y: auto;
}
@keyframes mobileMenuIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.mobile-nav-item {
display: flex;
align-items: center;
padding: 14px 16px;
color: var(--text-primary);
text-decoration: none;
font-family: var(--font-family-emoji), var(--font-family-base);
font-weight: var(--font-weight-medium);
font-size: 1rem;
border-radius: var(--radius-lg);
transition: background var(--duration-fast) var(--ease-out);
-webkit-tap-highlight-color: transparent;
min-height: 48px;
}
@media (min-width: 480px) {
.mobile-nav-item {
padding: 16px 18px;
font-size: 1.05rem;
}
}
.mobile-nav-item:active {
background: rgba(255, 255, 255, 0.08);
}
.mobile-user-info {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-6) var(--space-4);
margin-top: var(--space-2);
border-top: 0.5px solid var(--border-default);
color: var(--text-secondary);
font-family: var(--font-family-base);
font-size: 1rem;
}
.mobile-user-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ========================================
Main Content - Responsive
======================================== */
.app-main {
padding: 0;
}
/* ========================================
Form Controls - Responsive
======================================== */
.form-group {
margin-bottom: var(--space-4);
}
.form-group:last-child {
margin-bottom: 0;
}
/* Reduced motion: inherited from app.css (section 17) */
Whitespace-only changes.
@@ -0,0 +1,716 @@
@page "/customers"
@implements IAsyncDisposable
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject ILexwareSyncService LexwareSyncService
@inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession
@inject IJSRuntime JS
@using Lagerbestand.Components.Shared
<PageTitle>Kundenverwaltung - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Kundenverwaltung - Reifen Späth Lagerverwaltung"
Description="Verwaltung der Kundendaten in der Reifen Späth Lagerverwaltung."
Robots="noindex, nofollow" />
@if (!UserSession.IsAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Administratoren zugänglich.</p>
<a href="/tire-storage" class="btn btn-primary">Zur Lagerübersicht</a>
</section>
}
else
{
<div class="customers-page">
<header class="page-header">
<h1>Kundenverwaltung</h1>
<div class="header-actions">
@if (lexwareEnabled)
{
<button class="btn btn-secondary" @onclick="SyncFromLexware" disabled="@isSyncing" aria-label="Kunden von Lexware synchronisieren">
@if (isSyncing)
{
<span class="spinner-inline" aria-hidden="true"></span>
}
else
{
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
}
Lexware Sync
</button>
}
<button class="btn btn-primary" @onclick="ShowAddCustomer" aria-label="Neuen Kunden anlegen">
<span aria-hidden="true">+</span> Neuer Kunde
</button>
</div>
</header>
@if (!string.IsNullOrEmpty(syncMessage))
{
<div class="sync-message @syncMessageType" role="status">@syncMessage</div>
}
@* Search *@
<section class="search-bar" aria-label="Kundensuche">
<label for="customer-search" class="visually-hidden">Kunden suchen</label>
<input type="search"
id="customer-search"
class="search-input"
placeholder="Name, Fahrzeug oder Kundennummer..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="SearchCustomers" />
</section>
@* Customer List *@
@if (isLoading)
{
<div class="loading-state" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<span>Lade Kunden...</span>
</div>
}
else if (filteredCustomers.Count == 0)
{
<section class="empty-state" aria-labelledby="empty-title">
<div class="empty-icon" aria-hidden="true">👥</div>
<h2 id="empty-title">Keine Kunden</h2>
<p>@(string.IsNullOrEmpty(searchTerm) ? "Noch keine Kunden angelegt." : "Keine Ergebnisse für Ihre Suche.")</p>
</section>
}
else
{
<section class="customer-list" aria-label="Kundenliste" role="list">
@foreach (var customer in filteredCustomers)
{
<article @key="customer.Id" class="customer-card @(expandedId == customer.Id ? "expanded" : "")"
@onclick="() => ToggleCustomer(customer.Id)"
role="listitem"
aria-expanded="@(expandedId == customer.Id)">
<div class="customer-main">
<div class="customer-avatar" aria-hidden="true">
@(customer.FirstName?.Length > 0 ? customer.FirstName[0] : '?')@(customer.LastName.Length > 0 ? customer.LastName[0] : "")
</div>
<div class="customer-info">
<span class="customer-name">@customer.FullName</span>
@if (!string.IsNullOrEmpty(customer.Company))
{
<span class="customer-company">@customer.Company</span>
}
<span class="customer-number">@customer.CustomerNumber</span>
</div>
<div class="customer-meta">
@{
var activeVehicles = customer.Vehicles?.Where(v => v.IsActive).ToList() ?? new List<Vehicle>();
}
@if (activeVehicles.Any())
{
<span class="vehicle-make">@string.Join(", ", activeVehicles.Select(v => v.ShortName))</span>
}
<span class="tire-count">
@(customer.TireStacks?.Count(ts => ts.Status == TireStackStatus.Stored) ?? 0) Stapel
</span>
</div>
</div>
@if (expandedId == customer.Id)
{
<div class="customer-details" @onclick:stopPropagation="true">
<dl class="detail-grid">
@if (!string.IsNullOrEmpty(customer.Phone))
{
<div class="detail-item">
<dt class="label">Telefon</dt>
<dd><a href="tel:@customer.Phone" class="value link">@customer.Phone</a></dd>
</div>
}
@if (!string.IsNullOrEmpty(customer.Email))
{
<div class="detail-item">
<dt class="label">E-Mail</dt>
<dd><a href="mailto:@customer.Email" class="value link">@customer.Email</a></dd>
</div>
}
@if (customer.PrimaryVehicle is { } primaryVehicle)
{
<div class="detail-item">
<dt class="label">Fahrzeug</dt>
<dd class="value">@primaryVehicle.DisplayName</dd>
</div>
}
<div class="detail-item">
<dt class="label">Kunde seit</dt>
<dd class="value"><time datetime="@customer.CreatedAt.ToString("yyyy-MM-dd")">@customer.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
</div>
</dl>
@if (!string.IsNullOrEmpty(customer.Notes))
{
<div class="notes-section">
<span class="label">Notizen</span>
<span class="notes-text">@customer.Notes</span>
</div>
}
@* Customer's Tire Stacks *@
@if (customerTireStacks.TryGetValue(customer.Id, out var tireStacks) && tireStacks.Count > 0)
{
<section class="tireset-section" aria-labelledby="tireset-heading-@customer.Id">
<h3 id="tireset-heading-@customer.Id">Eingelagerte Reifenstapel</h3>
<ul>
@foreach (var ts in tireStacks.Where(t => t.Status == TireStackStatus.Stored))
{
<li @key="ts.Id" class="mini-tireset">
<span class="season" aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</span>
<span class="info">@ts.TireSize (@TireStack.TiresPerStack Reifen)</span>
<span class="location"><span aria-hidden="true">📍</span> @(ts.StorageLocation?.Code ?? "-")</span>
</li>
}
</ul>
</section>
}
<nav class="customer-actions" aria-label="Kundenaktionen">
<button class="btn btn-sm btn-outline" @onclick="() => EditCustomer(customer)">
Bearbeiten
</button>
<a href="/tire-checkin" class="btn btn-sm btn-primary">
Reifenstapel einlagern
</a>
</nav>
</div>
}
</article>
}
</section>
}
</div>
@* Add/Edit Customer Modal *@
@if (showModal)
{
<div class="modal-backdrop" @onclick="CloseModal"></div>
<div class="app-modal" @onclick="CloseModal" role="dialog" aria-modal="true">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(isEditing ? "Kunde bearbeiten" : "Neuer Kunde")</h5>
<button type="button" class="btn-close" @onclick="CloseModal" aria-label="Dialog schließen"></button>
</div>
<form @onsubmit="SaveCustomer" @onsubmit:preventDefault>
<div class="modal-body">
<div class="form-row-2col">
<div class="mb-3">
<label class="form-label">Vorname</label>
<input type="text" class="form-control" @bind="editCustomer.FirstName" />
</div>
<div class="mb-3">
<label class="form-label">Nachname <span class="required">*</span></label>
<input type="text" class="form-control" @bind="editCustomer.LastName" required />
</div>
</div>
<div class="mb-3">
<label class="form-label">Firma</label>
<input type="text" class="form-control" @bind="editCustomer.Company" />
</div>
<div class="form-row-2col">
<div class="mb-3">
<label class="form-label">Telefon</label>
<input type="tel" class="form-control" @bind="editCustomer.Phone" autocomplete="tel" />
</div>
<div class="mb-3">
<label class="form-label">E-Mail</label>
<input type="email" class="form-control" @bind="editCustomer.Email" autocomplete="email" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Notizen</label>
<textarea class="form-control" rows="2" @bind="editCustomer.Notes"></textarea>
</div>
@* Vehicle Management Section *@
<div class="vehicles-section">
<div class="vehicles-header">
<h6>Fahrzeuge</h6>
<button type="button" class="btn btn-sm btn-outline" @onclick="AddEditVehicle">
+ Fahrzeug hinzufügen
</button>
</div>
@if (editVehicles.Count == 0)
{
<p class="text-muted small">Keine Fahrzeuge hinterlegt</p>
}
else
{
<div class="vehicle-edit-list">
@foreach (var vehicle in editVehicles)
{
<div class="vehicle-edit-item @(vehicle.IsPrimary ? "primary" : "")">
<div class="vehicle-edit-info">
<span class="vehicle-make">@vehicle.Make @vehicle.Model</span>
@if (!string.IsNullOrEmpty(vehicle.TireSize))
{
<span class="vehicle-tiresize">@vehicle.TireSize</span>
}
@if (vehicle.IsPrimary)
{
<span class="primary-badge">Hauptfahrzeug</span>
}
</div>
<div class="vehicle-edit-actions">
@if (!vehicle.IsPrimary && editVehicles.Count > 1)
{
<button type="button" class="btn-icon" title="Als Hauptfahrzeug" @onclick="() => SetPrimaryVehicle(vehicle)">⭐</button>
}
<button type="button" class="btn-icon" title="Bearbeiten" @onclick="() => EditVehicle(vehicle)">✏️</button>
<button type="button" class="btn-icon danger" title="Entfernen" @onclick="() => RemoveVehicle(vehicle)">🗑️</button>
</div>
</div>
}
</div>
}
@if (showVehicleForm)
{
<div class="vehicle-form">
<div class="form-row-2col">
<div class="mb-2">
<label class="form-label small">Marke *</label>
<input type="text" class="form-control form-control-sm" @bind="editVehicle.Make" placeholder="z.B. BMW" />
</div>
<div class="mb-2">
<label class="form-label small">Modell</label>
<input type="text" class="form-control form-control-sm" @bind="editVehicle.Model" />
</div>
</div>
<div class="form-row-2col">
<div class="mb-2">
<label class="form-label small">Reifengröße *</label>
<input type="text" class="form-control form-control-sm" @bind="editVehicle.TireSize" placeholder="z.B. 225/45 R17" />
</div>
</div>
<div class="vehicle-form-actions">
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelVehicleForm">Abbrechen</button>
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveVehicle" disabled="@(string.IsNullOrWhiteSpace(editVehicle.Make) || string.IsNullOrWhiteSpace(editVehicle.TireSize))">
@(isEditingVehicle ? "Speichern" : "Hinzufügen")
</button>
</div>
</div>
}
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @onclick="CloseModal">Abbrechen</button>
<button type="submit" class="btn btn-primary" disabled="@(!CanSave)">
@(isEditing ? "Speichern" : "Anlegen")
</button>
</div>
</form>
</div>
</div>
</div>
}
}
@code {
private List<Customer> allCustomers = new();
private List<Customer> filteredCustomers = new();
private Dictionary<Guid, List<TireStack>> customerTireStacks = new();
private string searchTerm = "";
private Guid? expandedId;
private bool isLoading = true;
// Modal state
private bool showModal;
private bool isEditing;
private Customer editCustomer = new();
private string? errorMessage;
// Vehicle editing state
private List<Vehicle> editVehicles = new();
private Vehicle editVehicle = new();
private bool showVehicleForm;
private bool isEditingVehicle;
private List<Guid> vehiclesToDelete = new();
// Lexware sync state
private bool lexwareEnabled;
private bool isSyncing;
private string? syncMessage;
private string syncMessageType = "success";
private bool CanSave =>
!string.IsNullOrWhiteSpace(editCustomer.LastName);
protected override async Task OnInitializedAsync()
{
if (UserSession.IsAdmin)
{
await LoadCustomers();
await CheckLexwareEnabled();
}
}
private DotNetObjectReference<Customers>? _visibilityRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsAdmin)
{
_visibilityRef = DotNetObjectReference.Create(this);
await VisibilityRefresh.RegisterAsync(JS, _visibilityRef, nameof(OnVisibilityReturn), 120);
}
}
[JSInvokable]
public async Task OnVisibilityReturn(int hiddenSeconds)
{
if (!UserSession.IsAdmin) return;
await LoadCustomers();
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
await VisibilityRefresh.UnregisterAsync(JS);
_visibilityRef?.Dispose();
}
private async Task CheckLexwareEnabled()
{
try
{
var settings = await SiteSettingsService.GetLexwareApiSettingsAsync();
lexwareEnabled = settings.IsEnabled && settings.IsConfigured;
}
catch { }
}
private async Task SyncFromLexware()
{
isSyncing = true;
syncMessage = null;
StateHasChanged();
try
{
var result = await LexwareSyncService.SyncCustomersAsync();
syncMessage = result.Message;
syncMessageType = result.Success ? "success" : "error";
if (result.Success)
{
await LoadCustomers();
}
}
catch (Exception ex)
{
syncMessage = $"Fehler: {ex.Message}";
syncMessageType = "error";
}
finally
{
isSyncing = false;
}
}
private async Task LoadCustomers()
{
isLoading = true;
StateHasChanged();
try
{
allCustomers = await CustomerService.GetAllAsync();
filteredCustomers = allCustomers;
}
finally
{
isLoading = false;
}
}
private void SearchCustomers()
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
filteredCustomers = allCustomers;
return;
}
var term = searchTerm.ToLower();
filteredCustomers = allCustomers
.Where(c =>
c.FullName.ToLower().Contains(term) ||
(c.Company?.ToLower().Contains(term) ?? false) ||
c.Vehicles.Any(v => v.Make.ToLower().Contains(term) || (v.Model?.ToLower().Contains(term) ?? false)) ||
c.CustomerNumber.ToLower().Contains(term) ||
(c.Phone?.Contains(term) ?? false))
.ToList();
}
private async Task ToggleCustomer(Guid id)
{
if (expandedId == id)
{
expandedId = null;
return;
}
expandedId = id;
// Load tire stacks for this customer if not already loaded
if (!customerTireStacks.ContainsKey(id))
{
var tireStacks = await TireStackService.GetByCustomerAsync(id);
customerTireStacks[id] = tireStacks;
}
}
private void ShowAddCustomer()
{
editCustomer = new Customer();
editVehicles = new List<Vehicle>();
vehiclesToDelete = new List<Guid>();
isEditing = false;
errorMessage = null;
showVehicleForm = false;
showModal = true;
}
private async Task EditCustomer(Customer customer)
{
editCustomer = new Customer
{
Id = customer.Id,
CustomerNumber = customer.CustomerNumber,
FirstName = customer.FirstName,
LastName = customer.LastName,
Company = customer.Company,
Phone = customer.Phone,
Email = customer.Email,
Notes = customer.Notes
};
// Load vehicles for editing
var vehicles = await CustomerService.GetVehiclesAsync(customer.Id);
editVehicles = vehicles.Select(v => new Vehicle
{
Id = v.Id,
CustomerId = v.CustomerId,
Make = v.Make,
Model = v.Model,
TireSize = v.TireSize,
IsPrimary = v.IsPrimary,
IsActive = v.IsActive,
Notes = v.Notes
}).ToList();
vehiclesToDelete = new List<Guid>();
isEditing = true;
errorMessage = null;
showVehicleForm = false;
showModal = true;
}
private void CloseModal()
{
showModal = false;
editCustomer = new Customer();
editVehicles = new List<Vehicle>();
vehiclesToDelete = new List<Guid>();
showVehicleForm = false;
errorMessage = null;
}
// Vehicle management methods
private void AddEditVehicle()
{
editVehicle = new Vehicle();
isEditingVehicle = false;
showVehicleForm = true;
}
private void EditVehicle(Vehicle vehicle)
{
editVehicle = new Vehicle
{
Id = vehicle.Id,
CustomerId = vehicle.CustomerId,
Make = vehicle.Make,
Model = vehicle.Model,
TireSize = vehicle.TireSize,
IsPrimary = vehicle.IsPrimary,
IsActive = vehicle.IsActive,
Notes = vehicle.Notes
};
isEditingVehicle = true;
showVehicleForm = true;
}
private void CancelVehicleForm()
{
editVehicle = new Vehicle();
showVehicleForm = false;
isEditingVehicle = false;
}
private void SaveVehicle()
{
if (string.IsNullOrWhiteSpace(editVehicle.Make) || string.IsNullOrWhiteSpace(editVehicle.TireSize)) return;
if (isEditingVehicle)
{
var existing = editVehicles.FirstOrDefault(v => v.Id == editVehicle.Id);
if (existing != null)
{
existing.Make = editVehicle.Make;
existing.Model = editVehicle.Model;
existing.TireSize = editVehicle.TireSize;
existing.Notes = editVehicle.Notes;
}
}
else
{
var newVehicle = new Vehicle
{
Id = Guid.NewGuid(),
Make = editVehicle.Make,
Model = editVehicle.Model,
TireSize = editVehicle.TireSize,
IsPrimary = editVehicles.Count == 0, // First vehicle is primary
IsActive = true
};
editVehicles.Add(newVehicle);
}
showVehicleForm = false;
editVehicle = new Vehicle();
isEditingVehicle = false;
}
private void RemoveVehicle(Vehicle vehicle)
{
editVehicles.Remove(vehicle);
// Track for deletion if it's an existing vehicle
if (vehicle.CustomerId != Guid.Empty)
{
vehiclesToDelete.Add(vehicle.Id);
}
// Ensure there's still a primary if vehicles remain
if (editVehicles.Any() && !editVehicles.Any(v => v.IsPrimary))
{
editVehicles.First().IsPrimary = true;
}
}
private void SetPrimaryVehicle(Vehicle vehicle)
{
foreach (var v in editVehicles)
{
v.IsPrimary = v.Id == vehicle.Id;
}
}
private async Task SaveCustomer()
{
if (!CanSave) return;
try
{
if (isEditing)
{
var existing = allCustomers.First(c => c.Id == editCustomer.Id);
existing.FirstName = editCustomer.FirstName;
existing.LastName = editCustomer.LastName;
existing.Company = editCustomer.Company;
existing.Phone = editCustomer.Phone;
existing.Email = editCustomer.Email;
existing.Notes = editCustomer.Notes;
await CustomerService.UpdateAsync(existing);
// Delete removed vehicles
foreach (var vehicleId in vehiclesToDelete)
{
await CustomerService.DeleteVehicleAsync(vehicleId);
}
// Update or add vehicles
foreach (var vehicle in editVehicles)
{
if (vehicle.CustomerId == Guid.Empty)
{
// New vehicle
vehicle.CustomerId = existing.Id;
await CustomerService.AddVehicleAsync(existing.Id, vehicle);
}
else
{
// Existing vehicle
await CustomerService.UpdateVehicleAsync(vehicle);
}
}
// Handle primary vehicle
var primary = editVehicles.FirstOrDefault(v => v.IsPrimary);
if (primary != null)
{
await CustomerService.SetPrimaryVehicleAsync(existing.Id, primary.Id);
}
// Reload to get updated vehicles
await LoadCustomers();
}
else
{
// Create new customer
if (editVehicles.Any())
{
var firstVehicle = editVehicles.First();
var created = await CustomerService.CreateWithVehicleAsync(editCustomer, firstVehicle);
// Add remaining vehicles
for (int i = 1; i < editVehicles.Count; i++)
{
await CustomerService.AddVehicleAsync(created.Id, editVehicles[i]);
}
allCustomers.Insert(0, created);
}
else
{
var created = await CustomerService.CreateAsync(editCustomer);
allCustomers.Insert(0, created);
}
}
SearchCustomers();
CloseModal();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
}
@@ -0,0 +1,738 @@
/* ========================================
Customers Page Styles
======================================== */
.customers-page {
min-height: 100vh;
min-height: 100dvh;
background: linear-gradient(180deg, #000000 0%, #0a0a0a 100%);
padding: 0.75rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 480px) {
.customers-page {
padding: 1rem;
}
}
@media (min-width: 768px) {
.customers-page {
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
}
/* ========================================
Page Header
======================================== */
.page-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (min-width: 480px) {
.page-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
}
.page-header h1 {
font-family: 'Orbitron', monospace;
font-size: clamp(1.25rem, 4vw, 1.75rem);
font-weight: 700;
color: #ffffff;
margin: 0;
}
@media (max-width: 479.98px) {
.page-header .btn {
width: 100%;
justify-content: center;
}
}
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
@media (max-width: 479.98px) {
.header-actions {
width: 100%;
flex-direction: column;
}
.header-actions .btn {
width: 100%;
justify-content: center;
}
}
/* btn-secondary: inherited from app.css (section 5) */
.spinner-inline {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--text-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.sync-message {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-family: var(--font-family-base);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
text-align: center;
}
.sync-message.success {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
color: #86efac;
}
.sync-message.error {
background: rgba(220, 38, 38, 0.12);
border: 1px solid rgba(220, 38, 38, 0.25);
color: #fca5a5;
}
/* ========================================
Search Bar
======================================== */
.search-bar {
margin-bottom: 1rem;
}
@media (min-width: 480px) {
.search-bar {
margin-bottom: 1.5rem;
}
}
.search-input {
width: 100%;
padding: 12px 14px;
background: var(--surface-elevated);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-lg);
color: var(--text-primary);
font-family: var(--font-family-base);
font-size: 16px; /* Prevents iOS zoom */
box-sizing: border-box;
}
@media (min-width: 480px) {
.search-input {
padding: 14px 16px;
border-radius: 12px;
}
}
.search-input:focus {
outline: none;
border-color: var(--border-focus);
}
.search-input::placeholder {
color: var(--text-placeholder);
}
/* ========================================
Loading & Empty States
======================================== */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
text-align: center;
}
@media (min-width: 480px) {
.loading-state,
.empty-state {
padding: 4rem 2rem;
}
}
.loading-state {
gap: 12px;
color: rgba(255, 255, 255, 0.5);
font-family: 'Rajdhani', sans-serif;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--border-default);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@media (min-width: 480px) {
.spinner {
width: 32px;
height: 32px;
}
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
@media (min-width: 480px) {
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
}
.empty-state h3 {
font-family: 'Rajdhani', sans-serif;
font-size: clamp(1rem, 3vw, 1.25rem);
color: #ffffff;
margin: 0 0 0.5rem 0;
}
.empty-state p {
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
margin: 0;
font-size: 0.9rem;
}
/* ========================================
Customer List
======================================== */
.customer-list {
display: flex;
flex-direction: column;
gap: 8px;
}
@media (min-width: 480px) {
.customer-list {
gap: 10px;
}
}
.customer-card {
background: rgba(28, 28, 30, 0.8);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
@media (min-width: 480px) {
.customer-card {
border-radius: 16px;
}
}
.customer-card:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.customer-card.expanded {
border-color: rgba(220, 38, 38, 0.3);
}
.customer-main {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
@media (min-width: 480px) {
.customer-main {
gap: 14px;
padding: 14px 16px;
}
}
.customer-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Rajdhani', sans-serif;
font-weight: 700;
color: #ffffff;
font-size: 0.8rem;
flex-shrink: 0;
}
@media (min-width: 480px) {
.customer-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
font-size: 0.9rem;
}
}
.customer-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.customer-name {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
color: #ffffff;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 480px) {
.customer-name {
font-size: 1rem;
}
}
.customer-company {
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 480px) {
.customer-company {
font-size: 0.85rem;
}
}
.customer-number {
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
}
@media (min-width: 480px) {
.customer-number {
font-size: 0.8rem;
}
}
.customer-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.vehicle-make {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
}
@media (min-width: 480px) {
.vehicle-make {
padding: 3px 8px;
font-size: 0.8rem;
}
}
.tire-count {
font-family: 'Rajdhani', sans-serif;
font-size: 0.7rem;
color: #60a5fa;
}
@media (min-width: 480px) {
.tire-count {
font-size: 0.75rem;
}
}
/* ========================================
Customer Details (Expanded)
======================================== */
.customer-details {
padding: 0 14px 14px;
border-top: 0.5px solid rgba(255, 255, 255, 0.05);
cursor: default;
animation: slideDown 0.2s ease;
}
@media (min-width: 480px) {
.customer-details {
padding: 0 16px 16px;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
padding: 12px 0;
}
@media (min-width: 480px) {
.detail-grid {
gap: 12px;
padding: 14px 0;
}
}
.detail-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-item .label {
font-family: 'Rajdhani', sans-serif;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
}
@media (min-width: 480px) {
.detail-item .label {
font-size: 0.75rem;
}
}
.detail-item .value {
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: #ffffff;
word-break: break-word;
}
@media (min-width: 480px) {
.detail-item .value {
font-size: 0.9rem;
}
}
.detail-item .value.link {
color: #60a5fa;
text-decoration: none;
}
.detail-item .value.link:hover {
text-decoration: underline;
}
.notes-section {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 12px;
}
.notes-section .label {
font-family: 'Rajdhani', sans-serif;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
display: block;
margin-bottom: 4px;
}
.notes-text {
font-family: 'Rajdhani', sans-serif;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
}
/* ========================================
Tire Set Section
======================================== */
.tireset-section {
margin-bottom: 12px;
}
.tireset-section h4 {
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 8px 0;
}
@media (min-width: 480px) {
.tireset-section h4 {
font-size: 0.85rem;
}
}
.mini-tireset {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
margin-bottom: 6px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
}
@media (min-width: 480px) {
.mini-tireset {
gap: 10px;
padding: 8px 12px;
font-size: 0.9rem;
}
}
.mini-tireset .season {
font-size: 1rem;
}
.mini-tireset .info {
flex: 1;
color: #ffffff;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-tireset .location {
color: #60a5fa;
flex-shrink: 0;
}
/* ========================================
Customer Actions
======================================== */
.customer-actions {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 0.5px solid rgba(255, 255, 255, 0.05);
}
@media (min-width: 480px) {
.customer-actions {
flex-direction: row;
gap: 10px;
justify-content: flex-end;
}
}
@media (max-width: 479.98px) {
.customer-actions .btn {
width: 100%;
justify-content: center;
}
}
/* Buttons: inherited from app.css (section 5) */
/* ========================================
Form Row (2 columns on desktop)
======================================== */
.form-row-2col {
display: grid;
grid-template-columns: 1fr;
gap: 0;
}
@media (min-width: 480px) {
.form-row-2col {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}
/* ========================================
Vehicle Management Section
======================================== */
.vehicles-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
}
.vehicles-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.vehicles-header h6 {
font-family: 'Rajdhani', sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
margin: 0;
}
.vehicle-edit-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.vehicle-edit-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
border: 0.5px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
gap: 10px;
}
.vehicle-edit-item.primary {
background: rgba(96, 165, 250, 0.08);
border-color: rgba(96, 165, 250, 0.2);
}
.vehicle-edit-info {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
}
.vehicle-make {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
color: #ffffff;
font-size: 0.9rem;
}
.vehicle-tiresize {
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
}
.primary-badge {
font-family: 'Rajdhani', sans-serif;
font-size: 0.65rem;
font-weight: 600;
background: rgba(96, 165, 250, 0.15);
border: 1px solid rgba(96, 165, 250, 0.3);
color: #93c5fd;
padding: 2px 6px;
border-radius: 4px;
}
.vehicle-edit-actions {
display: flex;
gap: 4px;
}
.btn-icon {
background: transparent;
border: none;
padding: 6px;
cursor: pointer;
font-size: 0.9rem;
opacity: 0.6;
transition: opacity 0.2s ease;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
opacity: 1;
}
.btn-icon.danger:hover {
color: #ef4444;
}
.vehicle-form {
margin-top: 0.75rem;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.vehicle-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 0.5rem;
}
.text-muted {
color: rgba(255, 255, 255, 0.4);
}
.small {
font-size: 0.8rem;
}
.form-control-sm {
padding: 8px 10px !important;
font-size: 0.85rem !important;
}
.form-label.small {
font-size: 0.75rem;
margin-bottom: 4px;
}
.mb-2 {
margin-bottom: 0.5rem;
}
/* Reduced motion: inherited from app.css (section 17) */
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,156 @@
@page "/datenschutz"
@layout EmptyLayout
<PageTitle>Datenschutzerklärung | Reifen Späth Kfz-Meisterbetrieb</PageTitle>
<SeoMetaTags
Title="Datenschutzerklärung | Reifen Späth Kfz-Meisterbetrieb"
Description="Datenschutzerklärung von Reifen Späth. Informationen zur Datenverarbeitung, Cookies, Ihre Rechte nach DSGVO und Kontaktdaten des Verantwortlichen."
Keywords="Datenschutz, DSGVO, Cookies, Reifen Späth, Datenschutzerklärung, Datensicherheit"
CanonicalUrl="https://www.reifenspaeth.de/datenschutz"
OgTitle="Datenschutzerklärung | Reifen Späth"
OgType="website" />
<div class="datenschutz-page">
<header class="datenschutz-nav" role="banner">
<nav aria-label="Zurück-Navigation">
<a href="/" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M19 12H5M12 19l-7-7 7-7"></path>
</svg>
Zurück zur Startseite
</a>
</nav>
</header>
<main class="datenschutz-container" role="main">
<article>
<h1>Datenschutzerklärung</h1>
<p class="intro">Diese Datenschutzerklärung gilt für die Website <strong>www.reifenspaeth.de</strong>.</p>
<section class="datenschutz-section" aria-labelledby="verantwortlicher-heading">
<h2 id="verantwortlicher-heading">1. Verantwortlicher</h2>
<address>
<strong>Reifen Späth</strong><br />
Inhaber: Daniel Späth<br />
Sandizeller Straße 10C<br />
86571 Langenmosen<br />
Telefon: <a href="tel:+4915568569616">+49 (0)155 68569616</a><br />
E-Mail: <a href="mailto:info@reifenspaeth.de">info@reifenspaeth.de</a><br />
Website: <a href="https://www.reifenspaeth.de" rel="canonical">www.reifenspaeth.de</a>
</address>
</section>
<section class="datenschutz-section" aria-labelledby="allgemein-heading">
<h2 id="allgemein-heading">2. Allgemeine Hinweise zur Datenverarbeitung</h2>
<p>
Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Personenbezogene Daten werden vertraulich
und entsprechend den gesetzlichen Datenschutzvorschriften (DSGVO, BDSG) sowie dieser Datenschutzerklärung behandelt.
</p>
</section>
<section class="datenschutz-section" aria-labelledby="erhebung-heading">
<h2 id="erhebung-heading">3. Erhebung und Speicherung personenbezogener Daten sowie Art und Zweck von deren Verwendung</h2>
<h3>a) Beim Besuch der Website</h3>
<p>
Beim Aufrufen unserer Website werden automatisch Informationen (sogenannte Server-Logfiles)
an unseren Server übermittelt und temporär gespeichert. Dazu gehören u. a.:
</p>
<ul>
<li>IP-Adresse des anfragenden Rechners</li>
<li>Datum und Uhrzeit des Zugriffs</li>
<li>Name und URL der abgerufenen Datei</li>
<li>Referrer-URL (Website, von der aus der Zugriff erfolgt)</li>
<li>verwendeter Browser und Betriebssystem</li>
</ul>
<p>
Die Daten werden zu Zwecken der Sicherheit, zur Gewährleistung eines störungsfreien Betriebs
und zur administrativen Nutzung verarbeitet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse).
</p>
</section>
<section class="datenschutz-section" aria-labelledby="cookies-heading">
<h2 id="cookies-heading">4. Cookies und Tracking</h2>
<h3>a) Cookies</h3>
<p>
Unsere Website verwendet Cookies. Sie können Ihren Browser so einstellen, dass Sie über das Setzen
von Cookies informiert werden oder Cookies blockieren. Bitte beachten Sie, dass dadurch die Funktionalität
der Website eingeschränkt sein kann.
</p>
<h3>b) Tracking – Google Analytics</h3>
<p>
Auf dieser Website wird <strong>Google Analytics</strong> eingesetzt. Google Analytics verwendet
Cookies zur Analyse der Nutzung der Website. Die IP-Anonymisierung ist aktiviert, sodass IP-Adressen
innerhalb der EU/EWR gekürzt werden, bevor sie an Google übermittelt werden.
</p>
<p>
Die Erfassung ist nur aktiv, wenn Sie Ihre Zustimmung mittels des Consent-Banners erteilt haben.
Sie können die Speicherung der Cookies über Ihren Browser deaktivieren oder das Browser-Add-on
zur Deaktivierung von Google Analytics installieren:
<a href="https://tools.google.com/dlpage/gaoptout" target="_blank" rel="noopener noreferrer">https://tools.google.com/dlpage/gaoptout</a>.
</p>
</section>
<section class="datenschutz-section" aria-labelledby="weitergabe-heading">
<h2 id="weitergabe-heading">5. Datenweitergabe</h2>
<p>
Eine Übermittlung personenbezogener Daten an Dritte erfolgt nur, wenn Sie eingewilligt haben,
die Verarbeitung zur Vertragserfüllung erforderlich ist oder eine gesetzliche Verpflichtung besteht.
</p>
</section>
<section class="datenschutz-section" aria-labelledby="hosting-heading">
<h2 id="hosting-heading">6. Server-Hosting</h2>
<p>
Die Website wird auf einem selbst betriebenen Server in Deutschland gehostet (self-host).
Die gespeicherten Daten (z. B. Logfiles) werden intern verarbeitet und nicht ohne Notwendigkeit an Dritte weitergegeben.
</p>
</section>
<section class="datenschutz-section" aria-labelledby="rechte-heading">
<h2 id="rechte-heading">7. Ihre Rechte</h2>
<p>Sie haben die folgenden Rechte hinsichtlich gespeicherter personenbezogener Daten:</p>
<ul>
<li>Auskunft (Art. 15 DSGVO)</li>
<li>Berichtigung (Art. 16 DSGVO)</li>
<li>Löschung (Art. 17 DSGVO)</li>
<li>Einschränkung der Verarbeitung (Art. 18 DSGVO)</li>
<li>Widerspruch (Art. 21 DSGVO)</li>
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
</ul>
<p>Zur Ausübung Ihrer Rechte genügt eine Nachricht an: <a href="mailto:info@reifenspaeth.de">info@reifenspaeth.de</a>.</p>
</section>
<section class="datenschutz-section" aria-labelledby="beschwerde-heading">
<h2 id="beschwerde-heading">8. Beschwerderecht bei der Aufsichtsbehörde</h2>
<p>
Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. In Bayern z. B.:
</p>
<address>
<strong>Bayerische Landesbeauftragte für den Datenschutz (BayLfD)</strong><br />
Wagmüllerstraße 18, 80538 München<br />
Telefon: +49 89 212672-0<br />
Website: <a href="https://www.datenschutz-bayern.de" target="_blank" rel="noopener noreferrer">https://www.datenschutz-bayern.de</a>
</address>
</section>
<section class="datenschutz-section" aria-labelledby="stand-heading">
<h2 id="stand-heading">9. Stand und Aktualisierung</h2>
<p>
Diese Datenschutzerklärung hat den Stand <time datetime="2025-10-21"><strong>21. Oktober 2025</strong></time>.
Wir behalten uns vor, die Erklärung bei Bedarf anzupassen.
</p>
</section>
</article>
<footer class="datenschutz-footer">
<p><small>© Reifen Späth – Sandizeller Straße 10C, 86571 Langenmosen – <a href="mailto:info@reifenspaeth.de">info@reifenspaeth.de</a></small></p>
<nav aria-label="Fußzeilen-Navigation">
<a href="/" class="footer-back-link">Zurück zur Startseite</a>
</nav>
</footer>
</main>
</div>
@@ -0,0 +1,187 @@
/* ========================================
Datenschutz (Privacy) Page - Scoped Styles
========================================
Inherits from app.css: design tokens, btn styles.
======================================== */
.datenschutz-page {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-bg-gradient);
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
}
.datenschutz-nav {
padding: var(--space-6);
border-bottom: 1px solid var(--border-light);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-primary-light);
text-decoration: none;
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
transition: all var(--duration-fast) var(--ease-out);
}
.back-link:hover {
color: var(--color-primary);
text-decoration: none;
transform: translateX(-4px);
}
.back-link svg {
transition: transform var(--duration-fast) var(--ease-out);
}
.back-link:hover svg {
transform: translateX(-4px);
}
.datenschutz-container {
max-width: 900px;
margin: 0 auto;
padding: var(--space-10) var(--space-6);
}
.datenschutz-container h1 {
font-family: var(--font-family-heading);
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin-bottom: var(--space-4);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.intro {
font-size: var(--text-lg);
color: var(--text-secondary);
margin-bottom: var(--space-10);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--border-light);
}
.intro strong {
color: var(--text-primary);
}
.datenschutz-section {
margin-bottom: var(--space-10);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--border-light);
}
.datenschutz-section:last-of-type {
border-bottom: none;
}
.datenschutz-section h2 {
font-family: var(--font-family-heading);
font-size: clamp(1.2rem, 3vw, 1.4rem);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-4);
margin-top: 0;
}
.datenschutz-section h3 {
font-family: var(--font-family-base);
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-primary-light);
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
.datenschutz-section p {
color: var(--text-secondary);
margin-bottom: var(--space-4);
line-height: var(--line-height-relaxed);
}
.datenschutz-section strong {
color: var(--text-primary);
}
.datenschutz-section a {
color: var(--color-primary-light);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
.datenschutz-section a:hover {
color: var(--color-primary);
text-decoration: underline;
}
.datenschutz-section ul {
margin: var(--space-4) 0;
padding-left: var(--space-6);
color: var(--text-secondary);
}
.datenschutz-section ul li {
margin-bottom: var(--space-2);
position: relative;
}
.datenschutz-section ul li::marker {
color: var(--color-primary-light);
}
.datenschutz-footer {
margin-top: var(--space-10);
padding-top: var(--space-8);
border-top: 1px solid var(--border-default);
text-align: center;
}
.datenschutz-footer p {
color: var(--text-muted);
font-family: var(--font-family-base);
font-size: var(--text-sm);
margin-bottom: var(--space-6);
}
.datenschutz-footer a {
color: var(--color-primary-light);
text-decoration: none;
}
.datenschutz-footer a:hover {
text-decoration: underline;
}
.footer-back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-primary-light);
text-decoration: none;
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
padding: var(--space-3) var(--space-6);
border: 1px solid var(--color-primary);
border-radius: var(--radius-md);
transition: all var(--duration-normal) var(--ease-out);
}
.footer-back-link:hover {
background: var(--color-primary);
color: var(--text-primary);
text-decoration: none;
box-shadow: 0 0 20px rgba(220, 38, 38, 0.5);
}
@media (max-width: 768px) {
.datenschutz-container {
padding: var(--space-8) var(--space-4);
}
}
+36
View File
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+518
View File
@@ -0,0 +1,518 @@
@page "/"
@layout EmptyLayout
@using Lagerbestand.Models
@inject UserSessionService UserSession
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject IAuthService AuthService
@inject ISiteSettingsService SiteSettingsService
<PageTitle>Reifen Späth Langenmosen | Reifenwechsel, Felgen & Reifeneinlagerung</PageTitle>
<SeoMetaTags
Title="Reifen Späth Langenmosen | Reifenwechsel, Felgen & Reifeneinlagerung"
Description="Reifen Späth in Langenmosen: Kfz-Meisterbetrieb für Reifenwechsel, Felgen-Service, Sommerreifen, Winterreifen und Reifeneinlagerung nahe Neuburg und Schrobenhausen."
Keywords="Reifen Langenmosen, Reifenwechsel Langenmosen, Reifeneinlagerung, Felgen-Service, Winterreifen, Sommerreifen, Reifenservice Neuburg, Reifenservice Schrobenhausen"
CanonicalUrl="https://www.reifenspaeth.de/"
OgTitle="Reifen Späth Langenmosen - Reifenwechsel, Felgen & Einlagerung"
OgDescription="Kfz-Meisterbetrieb für Reifen, Felgen, Reifenwechsel und professionelle Reifeneinlagerung in Langenmosen."
OgImage="https://www.reifenspaeth.de/favicon.png"
OgType="website"
JsonLd="@GetHomePageJsonLd()" />
<div class="landing-page">
@* Cookie Consent Banner *@
@if (!cookieConsent)
{
<aside class="cookie-banner" role="dialog" aria-label="Cookie-Hinweis" aria-describedby="cookie-message">
<div class="cookie-content">
<p id="cookie-message">
Diese Website verwendet Cookies, um Ihnen das bestmögliche Erlebnis zu bieten.
Mit der Nutzung stimmen Sie unserer <a href="/datenschutz">Datenschutzerklärung</a> zu.
</p>
<button class="cookie-accept-btn" @onclick="AcceptCookies">OK, verstanden</button>
</div>
</aside>
}
@* Navigation *@
<header class="landing-header">
<nav class="landing-nav" aria-label="Hauptnavigation">
<div class="nav-container">
<a href="/" class="nav-brand" aria-label="Reifen Späth - Zur Startseite">
<span class="brand-name">REIFEN SPÄTH</span>
<span class="brand-subtitle">Kfz-Meisterbetrieb</span>
</a>
<div class="nav-links desktop-nav">
<a href="#home" class="nav-link">Start</a>
<a href="#about" class="nav-link">Über Uns</a>
<a href="#services" class="nav-link">Services</a>
<a href="#contact" class="nav-link">Kontakt</a>
@if (UserSession.IsAuthenticated)
{
<a href="/tire-storage" class="nav-link nav-link-highlight">Verwaltung</a>
}
else
{
<a href="/login" class="nav-link nav-link-highlight">Login</a>
}
</div>
<button class="mobile-menu-btn"
@onclick="ToggleMobileMenu"
aria-expanded="@showMobileMenu"
aria-controls="mobile-nav"
aria-label="Menü öffnen">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<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)
{
<div class="mobile-nav" id="mobile-nav" role="menu">
<a href="#home" class="nav-link" @onclick="CloseMobileMenu" role="menuitem">Start</a>
<a href="#about" class="nav-link" @onclick="CloseMobileMenu" role="menuitem">Über Uns</a>
<a href="#services" class="nav-link" @onclick="CloseMobileMenu" role="menuitem">Services</a>
<a href="#contact" class="nav-link" @onclick="CloseMobileMenu" role="menuitem">Kontakt</a>
@if (UserSession.IsAuthenticated)
{
<a href="/tire-storage" class="nav-link nav-link-highlight" @onclick="CloseMobileMenu" role="menuitem">Verwaltung</a>
}
else
{
<a href="/login" class="nav-link nav-link-highlight" @onclick="CloseMobileMenu" role="menuitem">Login</a>
}
</div>
}
</nav>
</header>
@* Promo Banner *@
@if (promoBanner != null && promoBanner.ShouldShow())
{
<aside class="promo-banner" role="complementary" aria-label="Aktuelle Aktion">
<div class="promo-content">
<div class="promo-text">
<strong class="promo-title">@promoBanner.Title</strong>
<span class="promo-message">@promoBanner.Message</span>
</div>
@if (promoBanner.ShowContactButton)
{
<a href="#contact" class="promo-cta">Termin vereinbaren</a>
}
</div>
</aside>
}
<main id="main-content" class="@(promoBanner != null && promoBanner.ShouldShow() ? "has-promo-banner" : "")">
@* Hero Section *@
<section id="home" class="hero-section" aria-labelledby="hero-title">
<div class="hero-overlay" aria-hidden="true"></div>
<div class="hero-content">
<h1 id="hero-title" class="hero-title">
<span class="title-main">REIFEN SPÄTH LANGENMOSEN</span>
<span class="title-accent">REIFENWECHSEL & FELGEN</span>
</h1>
<p class="hero-subtitle">
Kfz-Meisterbetrieb<br />
Ihr Spezialist für Reifenwechsel, Felgen-Service und Reifeneinlagerung nahe Neuburg an der Donau und Schrobenhausen
</p>
<div class="hero-buttons">
<a href="#services" class="btn-primary-landing">Reifen & Services</a>
<a href="#contact" class="btn-secondary-landing">Kontaktieren</a>
</div>
</div>
<div class="scroll-indicator" aria-hidden="true">
<div class="scroll-mouse">
<div class="scroll-wheel"></div>
</div>
</div>
</section>
@* About Section *@
<section id="about" class="about-section" aria-labelledby="about-title">
<div class="section-container">
<header class="section-header">
<h2 id="about-title" class="section-title">ÜBER UNS</h2>
<p class="section-description">
Als Kfz-Meisterbetrieb stehen wir für höchste Qualität und professionellen Service im Bereich Reifen und Felgen.
Unsere Meisterwerkstatt bietet Ihnen kompetente Beratung und präzise Ausführung aller Arbeiten - von Sommerreifen und Winterreifen bis zur sicheren Einlagerung.
</p>
</header>
<div class="features-grid">
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="feature-title">Meisterqualität</h3>
<p class="feature-description">Als Kfz-Meisterbetrieb garantieren wir höchste Qualitätsstandards und präzise Arbeit nach neuesten Technologien.</p>
</article>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h3 class="feature-title">Schneller Service</h3>
<p class="feature-description">Effiziente Terminvergabe und schnelle Ausführung aller Arbeiten - damit Sie schnell wieder sicher unterwegs sind.</p>
</article>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
</svg>
</div>
<h3 class="feature-title">Faire Preise</h3>
<p class="feature-description">Transparente Preisgestaltung und wettbewerbsfähige Angebote - Qualität zum fairen Preis.</p>
</article>
</div>
</div>
</section>
@* Services Section *@
<section id="services" class="services-section" aria-labelledby="services-title">
<div class="section-container">
<header class="section-header">
<h2 id="services-title" class="section-title">UNSERE SERVICES</h2>
<p class="section-description">
Von der Beratung bis zur Montage - wir bieten Ihnen alle Services rund um Ihre Reifen und Felgen aus einer Hand.
</p>
</header>
<div class="services-grid">
<article class="service-card">
<figure class="service-image">
<img src="https://kimi-web-img.moonshot.cn/img/www.baebeca.de/90680f6c86136388ba6d0854391af683785f106e.jpg"
alt="Professioneller Reifenwechsel bei Reifen Späth"
loading="lazy"
width="400"
height="300" />
</figure>
<h3 class="service-title">Reifenwechsel</h3>
<p class="service-description">Professioneller Reifenwechsel in Langenmosen mit Auswuchten, Druckkontrolle und Sichtprüfung für Sommerreifen und Winterreifen.</p>
</article>
<article class="service-card">
<figure class="service-image">
<img src="https://kimi-web-img.moonshot.cn/img/www.baebeca.de/a8f3a2174c0fa02578f3ea737ac23256932f8dc7.jpg"
alt="Felgen-Service - Alufelgen und Stahlfelgen"
loading="lazy"
width="400"
height="300" />
</figure>
<h3 class="service-title">Felgen-Service</h3>
<p class="service-description">Alufelgen, Stahlfelgen und Spezialfelgen - Beratung, Verkauf, Montage und Reparatur durch den Kfz-Meisterbetrieb.</p>
</article>
<article class="service-card">
<figure class="service-image">
<img src="https://kimi-web-img.moonshot.cn/img/static.felgenshop.de/3f45eac72b930d6fd7f1f03a9dabea122bcec79d.jpg"
alt="Professionelles Reifenlager für sichere Einlagerung"
loading="lazy"
width="400"
height="300" />
</figure>
<h3 class="service-title">Reifenlager</h3>
<p class="service-description">Professionelle Reifeneinlagerung: trocken, sauber und ganzjährig verfügbar für Kunden aus Langenmosen, Neuburg und Schrobenhausen.</p>
</article>
</div>
</div>
</section>
@* Local SEO Section *@
<section class="about-section" aria-labelledby="local-title">
<div class="section-container">
<header class="section-header">
<h2 id="local-title" class="section-title">REIFENSERVICE IN IHRER NÄHE</h2>
<p class="section-description">
Reifen Späth betreut Privat- und Gewerbekunden in Langenmosen sowie im Umkreis von Neuburg an der Donau, Schrobenhausen, Ingolstadt und Aichach-Friedberg.
</p>
</header>
<div class="features-grid">
<article class="feature-card">
<h3 class="feature-title">Reifenwechsel vor Ort</h3>
<p class="feature-description">Schnelle Termine für den saisonalen Wechsel auf Sommerreifen oder Winterreifen inklusive Kontrolle von Profil, Luftdruck und Zustand.</p>
</article>
<article class="feature-card">
<h3 class="feature-title">Felgen & Kompletträder</h3>
<p class="feature-description">Persönliche Beratung zu passenden Alufelgen, Stahlfelgen und Kompletträdern für Ihr Fahrzeug.</p>
</article>
<article class="feature-card">
<h3 class="feature-title">Einlagerung mit Service</h3>
<p class="feature-description">Ihre Räder werden fachgerecht gelagert und sind zur nächsten Saison wieder einsatzbereit.</p>
</article>
</div>
</div>
</section>
@* FAQ Section *@
<section class="services-section" aria-labelledby="faq-title">
<div class="section-container">
<header class="section-header">
<h2 id="faq-title" class="section-title">HÄUFIGE FRAGEN</h2>
</header>
<div class="services-grid">
<article class="service-card">
<h3 class="service-title">Wann sollte ich die Reifen wechseln?</h3>
<p class="service-description">Sommerreifen eignen sich für warme Monate, Winterreifen für niedrige Temperaturen und winterliche Straßen. Vereinbaren Sie rechtzeitig vor der Saison einen Termin.</p>
</article>
<article class="service-card">
<h3 class="service-title">Bietet Reifen Späth Reifeneinlagerung an?</h3>
<p class="service-description">Ja. Ihre Reifen oder Kompletträder können professionell eingelagert werden, damit sie trocken und sauber bis zur nächsten Saison aufbewahrt sind.</p>
</article>
<article class="service-card">
<h3 class="service-title">Wie erreiche ich den Reifenservice?</h3>
<p class="service-description">Sie erreichen Reifen Späth telefonisch, per E-Mail oder direkt per WhatsApp. Die Werkstatt befindet sich in der Sandizeller Str. 10C in 86571 Langenmosen.</p>
</article>
</div>
</div>
</section>
@* CTA Section *@
<section class="cta-section" aria-labelledby="cta-title">
<div class="section-container">
<h2 id="cta-title" class="cta-title">BEREIT FÜR NEUE REIFEN?</h2>
<p class="cta-description">
Vereinbaren Sie jetzt Ihren Termin oder kontaktieren Sie uns für eine persönliche Beratung.
Wir stehen Ihnen gerne zur Verfügung.
</p>
<div class="cta-buttons">
<a href="https://wa.me/+4915568569616" class="btn-cta-primary" target="_blank" rel="noopener noreferrer" aria-label="Kontaktieren Sie uns via WhatsApp">
WhatsApp
</a>
<a href="tel:+4915568569616" class="btn-cta-secondary" aria-label="Rufen Sie uns an">
Anrufen: +49 155 68569616
</a>
</div>
</div>
</section>
</main>
@* Footer *@
<footer id="contact" class="footer-section" aria-labelledby="footer-title">
<div class="section-container">
<div class="footer-grid">
<div class="footer-col">
<h3 id="footer-title" class="footer-brand">REIFEN SPÄTH</h3>
<p class="footer-subtitle">Kfz-Meisterbetrieb</p>
<p class="footer-text">
Daniel Späth,<br />
Ihr Spezialist für Premium Reifen und Felgen in Langenmosen.
</p>
<nav class="footer-links" aria-label="Rechtliche Links">
<a href="/impressum" class="footer-link">Impressum</a>
<a href="/datenschutz" class="footer-link">Datenschutz</a>
</nav>
</div>
<div class="footer-col">
<h4 class="footer-title">KONTAKT</h4>
<address class="footer-contact">
<p><span aria-hidden="true">📍</span> <span>Sandizeller Str. 10C, 86571 Langenmosen</span></p>
<p><span aria-hidden="true">📞</span> <a href="tel:+4915568569616">+49 155 68569616</a></p>
<p><span aria-hidden="true">✉</span> <a href="mailto:info@reifenspaeth.de">info@reifenspaeth.de</a></p>
<p><span aria-hidden="true">🌐</span> <span>www.reifenspaeth.de</span></p>
</address>
</div>
<div class="footer-col">
<h4 class="footer-title">ÖFFNUNGSZEITEN</h4>
<div class="footer-hours" role="list" aria-label="Öffnungszeiten">
<p role="listitem">Mo-Do: Geschlossen</p>
<p role="listitem">Freitag: 15:00 - 18:00 Uhr</p>
<p role="listitem">Samstag: 9:00 - 16:00 Uhr</p>
<p role="listitem">Sonntag: Geschlossen</p>
<p role="listitem">Termin nur nach Vereinbarung</p>
</div>
</div>
</div>
<div class="footer-bottom">
<p><small>© @DateTime.Now.Year Reifen Späth Kfz-Meisterbetrieb. Alle Rechte vorbehalten.</small></p>
</div>
</div>
</footer>
</div>
@code {
private bool showMobileMenu;
private bool cookieConsent = true;
private PromoBannerSettings? promoBanner;
protected override async Task OnInitializedAsync()
{
await LoadBannerAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await CheckCookieConsentAsync();
if (!UserSession.IsAuthenticated)
{
await TryRestoreSessionAsync();
}
}
}
private async Task LoadBannerAsync()
{
try
{
promoBanner = await SiteSettingsService.GetPromoBannerSettingsAsync();
}
catch
{
// Ignore errors loading banner - will show no banner
promoBanner = null;
}
}
private async Task CheckCookieConsentAsync()
{
try
{
var consent = await JS.InvokeAsync<string?>("localStorage.getItem", "cookieConsent");
cookieConsent = consent == "true";
StateHasChanged();
}
catch
{
// Ignore JS errors
}
}
private async Task AcceptCookies()
{
try
{
await JS.InvokeVoidAsync("localStorage.setItem", "cookieConsent", "true");
cookieConsent = true;
StateHasChanged();
}
catch
{
// Ignore JS errors
}
}
private async Task TryRestoreSessionAsync()
{
try
{
var userIdStr = await JS.InvokeAsync<string?>("localStorage.getItem", "userId");
if (string.IsNullOrWhiteSpace(userIdStr) || !Guid.TryParse(userIdStr, out var userId))
return;
var user = await AuthService.GetUserByIdAsync(userId);
if (user != null && user.IsActive)
{
UserSession.SetUser(user);
StateHasChanged();
}
else
{
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
}
}
catch
{
// Ignore JS errors
}
}
private void ToggleMobileMenu() => showMobileMenu = !showMobileMenu;
private void CloseMobileMenu() => showMobileMenu = false;
private string GetHomePageJsonLd()
{
return @"{
""@context"": ""https://schema.org"",
""@type"": ""WebPage"",
""name"": ""Reifen Späth Langenmosen - Reifenwechsel, Felgen & Reifeneinlagerung"",
""description"": ""Kfz-Meisterbetrieb für Reifenwechsel, Felgen-Service, Sommerreifen, Winterreifen und professionelle Reifeneinlagerung in Langenmosen, Bayern."",
""url"": ""https://www.reifenspaeth.de/"",
""mainEntity"": {
""@type"": ""AutoRepair"",
""name"": ""Reifen Späth Kfz-Meisterbetrieb"",
""telephone"": ""+4915568569616"",
""address"": {
""@type"": ""PostalAddress"",
""streetAddress"": ""Sandizeller Str. 10C"",
""postalCode"": ""86571"",
""addressLocality"": ""Langenmosen"",
""addressRegion"": ""Bayern"",
""addressCountry"": ""DE""
},
""areaServed"": [""Langenmosen"", ""Neuburg an der Donau"", ""Schrobenhausen"", ""Ingolstadt"", ""Aichach-Friedberg""],
""makesOffer"": [
{
""@type"": ""Offer"",
""itemOffered"": {
""@type"": ""Service"",
""name"": ""Reifenwechsel"",
""description"": ""Reifenwechsel mit Auswuchten, Druckkontrolle und Sichtprüfung.""
}
},
{
""@type"": ""Offer"",
""itemOffered"": {
""@type"": ""Service"",
""name"": ""Felgen-Service"",
""description"": ""Beratung, Verkauf, Montage und Reparatur von Felgen.""
}
},
{
""@type"": ""Offer"",
""itemOffered"": {
""@type"": ""Service"",
""name"": ""Reifeneinlagerung"",
""description"": ""Professionelle Einlagerung von Reifen und Kompletträdern.""
}
}
]
},
""mainEntityOfPage"": {
""@type"": ""FAQPage"",
""mainEntity"": [
{
""@type"": ""Question"",
""name"": ""Wann sollte ich die Reifen wechseln?"",
""acceptedAnswer"": {
""@type"": ""Answer"",
""text"": ""Sommerreifen eignen sich für warme Monate, Winterreifen für niedrige Temperaturen und winterliche Straßen. Vereinbaren Sie rechtzeitig vor der Saison einen Termin.""
}
},
{
""@type"": ""Question"",
""name"": ""Bietet Reifen Späth Reifeneinlagerung an?"",
""acceptedAnswer"": {
""@type"": ""Answer"",
""text"": ""Ja. Ihre Reifen oder Kompletträder können professionell eingelagert werden, damit sie trocken und sauber bis zur nächsten Saison aufbewahrt sind.""
}
},
{
""@type"": ""Question"",
""name"": ""Wie erreiche ich den Reifenservice?"",
""acceptedAnswer"": {
""@type"": ""Answer"",
""text"": ""Sie erreichen Reifen Späth telefonisch, per E-Mail oder direkt per WhatsApp. Die Werkstatt befindet sich in der Sandizeller Str. 10C in 86571 Langenmosen.""
}
}
]
},
""breadcrumb"": {
""@type"": ""BreadcrumbList"",
""itemListElement"": [{
""@type"": ""ListItem"",
""position"": 1,
""name"": ""Startseite"",
""item"": ""https://www.reifenspaeth.de/""
}]
}
}";
}
}
@@ -0,0 +1,885 @@
/* ========================================
Landing Page - Apple-Inspired Design
Uses global CSS variables from app.css
======================================== */
.landing-page {
background: linear-gradient(135deg, #000000 0%, #1a1a1a 50%, #000000 100%);
color: var(--text-primary);
min-height: 100vh;
}
/* Reset address element - fix Firefox/Safari italic default */
.landing-page address {
font-style: normal;
font-family: inherit;
}
/* Prevent auto-focus highlight */
.landing-page *:focus {
outline: none;
}
/* Only show focus outline on interactive elements when using keyboard */
.landing-page a:focus-visible,
.landing-page button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove any default focus styles from non-interactive elements */
.landing-page h1,
.landing-page h2,
.landing-page h3,
.landing-page h4,
.landing-page p,
.landing-page span,
.landing-page div,
.landing-page section {
outline: none !important;
-webkit-user-select: none;
user-select: none;
}
.landing-page a,
.landing-page button {
-webkit-tap-highlight-color: transparent;
}
/* ========================================
Navigation - Apple-style
======================================== */
.landing-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-modal);
background: rgba(26, 26, 26, 0.72);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--border-default);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-4);
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
display: flex;
flex-direction: column;
}
.brand-name {
font-family: var(--font-family-heading);
font-weight: var(--font-weight-bold);
font-size: 1.25rem;
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-subtitle {
font-family: var(--font-family-base);
font-size: 0.75rem;
color: var(--text-tertiary);
}
.nav-links {
display: flex;
gap: var(--space-2);
}
.nav-link {
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
transition: all var(--duration-fast) var(--ease-default);
}
.nav-link:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
.nav-link-highlight {
background: rgba(220, 38, 38, 0.2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
border: 1px solid rgba(220, 38, 38, 0.4);
}
.nav-link-highlight:hover {
background: rgba(220, 38, 38, 0.4);
}
.desktop-nav {
display: none;
}
.mobile-menu-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: var(--space-2);
min-height: 44px;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-nav {
background: rgba(0, 0, 0, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
@media (min-width: 768px) {
.desktop-nav {
display: flex;
}
.mobile-menu-btn {
display: none;
}
}
/* ========================================
Hero Section
======================================== */
.hero-section {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
linear-gradient(135deg, #1a1a1a 0%, #000000 100%);
background-size: cover;
background-position: center;
background-attachment: fixed;
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to right, rgba(0,0,0,0.5), transparent, rgba(0,0,0,0.5));
}
.hero-content {
position: relative;
z-index: 10;
text-align: center;
max-width: 800px;
padding: 0 var(--space-4);
}
.hero-title {
margin-bottom: var(--space-6);
}
.title-main {
display: block;
font-family: var(--font-family-heading);
font-size: clamp(2rem, 8vw, 4.5rem);
font-weight: 900;
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: var(--line-height-tight);
}
.title-accent {
display: block;
font-family: var(--font-family-heading);
font-size: clamp(2rem, 8vw, 4.5rem);
font-weight: 900;
color: var(--color-primary);
line-height: var(--line-height-tight);
}
.hero-subtitle {
font-family: var(--font-family-base);
font-size: clamp(1rem, 3vw, 1.5rem);
font-weight: 300;
color: #d1d5db;
margin-bottom: var(--space-8);
line-height: var(--line-height-relaxed);
}
.hero-buttons {
display: flex;
flex-direction: column;
gap: var(--space-4);
justify-content: center;
align-items: center;
}
@media (min-width: 640px) {
.hero-buttons {
flex-direction: row;
}
}
.btn-primary-landing {
display: inline-block;
background: var(--color-primary);
color: var(--text-primary);
padding: var(--space-4) var(--space-8);
border-radius: var(--radius-sm);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: 1.1rem;
text-decoration: none;
transition: all var(--duration-normal) var(--ease-default);
box-shadow: var(--shadow-glow);
}
.btn-primary-landing:hover {
background: var(--color-primary-dark);
box-shadow: 0 0 30px rgba(220, 38, 38, 0.8);
transform: translateY(-2px);
}
.btn-secondary-landing {
display: inline-block;
background: var(--text-primary);
color: var(--color-primary);
padding: var(--space-4) var(--space-8);
border-radius: var(--radius-sm);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: 1.1rem;
text-decoration: none;
transition: all var(--duration-normal) var(--ease-default);
}
.btn-secondary-landing:hover {
background: #f3f4f6;
}
.scroll-indicator {
position: absolute;
bottom: var(--space-8);
left: 50%;
transform: translateX(-50%);
animation: bounce 2s infinite;
}
.scroll-mouse {
width: 24px;
height: 40px;
border: 2px solid var(--text-primary);
border-radius: 12px;
display: flex;
justify-content: center;
padding-top: 8px;
}
.scroll-wheel {
width: 4px;
height: 12px;
background: var(--text-primary);
border-radius: 2px;
animation: pulse 1.5s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-10px); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ========================================
Section Styles
======================================== */
.section-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-4);
}
.section-header {
text-align: center;
margin-bottom: var(--space-12);
}
.section-title {
font-family: var(--font-family-heading);
font-size: clamp(1.75rem, 5vw, 3rem);
font-weight: var(--font-weight-bold);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-4);
}
.section-description {
font-family: var(--font-family-base);
font-size: 1.25rem;
color: #d1d5db;
max-width: 700px;
margin: 0 auto;
line-height: var(--line-height-relaxed);
}
/* ========================================
About Section
======================================== */
.about-section {
padding: var(--space-12) 0;
background: linear-gradient(to bottom, #111827, #000000);
}
.features-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 768px) {
.features-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.feature-card {
background: rgba(31, 41, 55, 0.5);
border: 1px solid rgba(192, 192, 192, 0.2);
border-radius: var(--radius-lg);
padding: var(--space-8);
text-align: center;
transition: all var(--duration-normal) var(--ease-default);
}
.feature-card:hover {
transform: translateY(-5px);
border-color: var(--color-primary);
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.3);
}
.feature-icon {
width: 64px;
height: 64px;
background: var(--color-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--space-4);
color: var(--text-primary);
}
.feature-title {
font-family: var(--font-family-heading);
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-3);
}
.feature-description {
font-family: var(--font-family-base);
color: #d1d5db;
line-height: var(--line-height-relaxed);
}
/* ========================================
Services Section
======================================== */
.services-section {
padding: var(--space-12) 0;
background: var(--color-bg);
min-height: 100vh;
display: flex;
align-items: center;
}
.services-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-6);
}
.service-card {
background: #111827;
border: 1px solid rgba(192, 192, 192, 0.2);
border-radius: var(--radius-lg);
padding: var(--space-6);
text-align: center;
width: 100%;
max-width: 300px;
transition: all var(--duration-normal) var(--ease-default);
}
.service-card:hover {
transform: translateY(-5px);
border-color: var(--color-primary);
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.3);
}
.service-image {
margin-bottom: var(--space-4);
border-radius: var(--radius-sm);
overflow: hidden;
}
.service-image img {
width: 100%;
height: 180px;
object-fit: cover;
border-radius: var(--radius-sm);
transition: transform var(--duration-normal) var(--ease-default);
}
.service-card:hover .service-image img {
transform: scale(1.05);
}
.service-title {
font-family: var(--font-family-heading);
font-size: 1.1rem;
font-weight: var(--font-weight-bold);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-2);
}
.service-description {
font-family: var(--font-family-base);
font-size: 0.9rem;
color: var(--text-tertiary);
line-height: var(--line-height-normal);
}
/* ========================================
CTA Section
======================================== */
.cta-section {
padding: var(--space-12) 0;
background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 100%);
text-align: center;
}
.cta-title {
font-family: var(--font-family-heading);
font-size: clamp(1.75rem, 5vw, 3rem);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin-bottom: var(--space-4);
}
.cta-description {
font-family: var(--font-family-base);
font-size: 1.25rem;
color: #fecaca;
max-width: 600px;
margin: 0 auto var(--space-8);
line-height: var(--line-height-relaxed);
}
.cta-buttons {
display: flex;
flex-direction: column;
gap: var(--space-4);
justify-content: center;
align-items: center;
}
@media (min-width: 640px) {
.cta-buttons {
flex-direction: row;
}
}
.btn-cta-primary {
display: inline-block;
background: var(--text-primary);
color: var(--color-primary);
padding: var(--space-4) var(--space-8);
border-radius: var(--radius-sm);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: 1.1rem;
text-decoration: none;
transition: all var(--duration-normal) var(--ease-default);
}
.btn-cta-primary:hover {
background: #f3f4f6;
}
.btn-cta-secondary {
display: inline-block;
background: transparent;
color: var(--text-primary);
padding: var(--space-4) var(--space-8);
border: 2px solid var(--text-primary);
border-radius: var(--radius-sm);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: 1.1rem;
text-decoration: none;
transition: all var(--duration-normal) var(--ease-default);
}
.btn-cta-secondary:hover {
background: var(--text-primary);
color: var(--color-primary);
}
/* ========================================
Footer
======================================== */
.footer-section {
background: var(--color-bg);
border-top: 1px solid rgba(128, 128, 128, 0.2);
padding: var(--space-12) 0 var(--space-6);
}
.footer-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-8);
margin-bottom: var(--space-8);
}
@media (min-width: 768px) {
.footer-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.footer-col {
text-align: center;
}
@media (min-width: 768px) {
.footer-col {
text-align: left;
}
}
.footer-brand {
font-family: var(--font-family-heading);
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-1);
}
.footer-subtitle {
font-family: var(--font-family-base);
color: var(--text-tertiary);
margin-bottom: var(--space-4);
}
.footer-text {
font-family: var(--font-family-base);
color: var(--text-tertiary);
line-height: var(--line-height-relaxed);
margin-bottom: var(--space-4);
}
.footer-link {
display: inline-block;
color: var(--color-primary-light);
text-decoration: none;
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
transition: all var(--duration-fast) var(--ease-default);
padding: var(--space-1) 0;
margin-right: var(--space-4);
}
.footer-link:hover {
color: var(--color-primary);
text-decoration: underline;
}
.footer-title {
font-family: var(--font-family-heading);
font-size: 1rem;
font-weight: var(--font-weight-bold);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-4);
}
.footer-contact,
.footer-hours {
font-family: var(--font-family-base), -apple-system, BlinkMacSystemFont, sans-serif;
font-style: normal; /* Reset italic from <address> default */
color: var(--text-tertiary);
line-height: 1.8;
}
.footer-contact p,
.footer-hours p {
margin: 0;
font-style: normal; /* Ensure no italic */
font-size: 0.95rem;
}
/* Emoji icons - consistent sizing */
.footer-contact p > span:first-child,
.footer-hours p > span:first-child {
display: inline-block;
width: 1.5em;
text-align: center;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.footer-contact a {
color: var(--text-tertiary);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-default);
font-style: normal;
}
.footer-contact a:hover {
color: var(--color-primary-light);
}
.footer-bottom {
border-top: 1px solid rgba(128, 128, 128, 0.2);
padding-top: var(--space-6);
text-align: center;
}
.footer-bottom p {
font-family: var(--font-family-base);
font-size: var(--text-sm);
color: var(--text-muted);
margin: 0;
}
/* ========================================
Cookie Banner
======================================== */
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(17, 24, 39, 0.98);
border-top: 1px solid rgba(220, 38, 38, 0.3);
padding: var(--space-4) var(--space-6);
z-index: calc(var(--z-modal) + 1);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
}
.cookie-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: center;
text-align: center;
}
@media (min-width: 768px) {
.cookie-content {
flex-direction: row;
justify-content: space-between;
text-align: left;
}
}
.cookie-content p {
font-family: var(--font-family-base);
font-size: 0.9rem;
color: #d1d5db;
margin: 0;
}
.cookie-content a {
color: var(--color-primary-light);
text-decoration: underline;
}
.cookie-content a:hover {
color: var(--color-primary);
}
.cookie-accept-btn {
background: var(--color-primary);
color: var(--text-primary);
border: none;
padding: 0.625rem var(--space-6);
border-radius: var(--radius-sm);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: 0.9rem;
cursor: pointer;
transition: all var(--duration-normal) var(--ease-default);
white-space: nowrap;
}
.cookie-accept-btn:hover {
background: var(--color-primary-dark);
box-shadow: 0 0 15px rgba(220, 38, 38, 0.5);
}
/* ========================================
Promo Banner - Gold/Amber Theme
======================================== */
.promo-banner {
background: linear-gradient(135deg, #b8860b 0%, #daa520 50%, #b8860b 100%);
color: #000;
padding: var(--space-3) var(--space-4);
position: fixed;
top: 64px; /* Below the fixed navigation */
left: 0;
right: 0;
z-index: calc(var(--z-modal) - 1); /* Just below navigation */
overflow: hidden;
box-shadow: 0 2px 10px rgba(218, 165, 32, 0.3);
}
.promo-banner::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.1) 50%, transparent 100%);
animation: promo-shimmer 3s infinite;
}
@keyframes promo-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.promo-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
position: relative;
z-index: 1;
flex-wrap: wrap;
}
.promo-text {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-1);
}
.promo-title {
font-family: var(--font-family-heading);
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #1a1a1a;
}
.promo-message {
font-size: 0.875rem;
font-weight: 500;
color: #2d2d2d;
max-width: 600px;
line-height: 1.4;
}
.promo-cta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background: #1a1a1a;
color: #daa520;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: 0.875rem;
text-decoration: none;
transition: all var(--duration-fast) var(--ease-default);
white-space: nowrap;
border: 1px solid transparent;
}
.promo-cta:hover {
background: #000;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Mobile: Compact layout */
@media (max-width: 640px) {
.promo-banner {
padding: var(--space-2) var(--space-3);
top: 64px;
}
.promo-content {
flex-direction: column;
gap: var(--space-2);
}
.promo-title {
font-size: 0.875rem;
}
.promo-message {
font-size: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.promo-cta {
font-size: 0.75rem;
padding: var(--space-1) var(--space-3);
}
}
/* Adjust hero section when promo banner is visible */
.has-promo-banner .hero-section {
padding-top: 60px; /* Space for the promo banner */
}
@media (max-width: 640px) {
.has-promo-banner .hero-section {
padding-top: 90px; /* More space on mobile due to stacked content */
}
}
@@ -0,0 +1,111 @@
@page "/impressum"
@layout EmptyLayout
<PageTitle>Impressum | Reifen Späth Kfz-Meisterbetrieb</PageTitle>
<SeoMetaTags
Title="Impressum | Reifen Späth Kfz-Meisterbetrieb"
Description="Impressum und rechtliche Angaben von Reifen Späth Kfz-Meisterbetrieb, Inhaber Daniel Späth, Langenmosen. Kontaktdaten und rechtliche Informationen."
Keywords="Impressum, Reifen Späth, Daniel Späth, Kfz-Meisterbetrieb, Langenmosen, Kontakt"
CanonicalUrl="https://www.reifenspaeth.de/impressum"
OgTitle="Impressum | Reifen Späth"
OgType="website" />
<div class="impressum-page">
<header class="impressum-nav" role="banner">
<nav aria-label="Zurück-Navigation">
<a href="/" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M19 12H5M12 19l-7-7 7-7"></path>
</svg>
Zurück zur Startseite
</a>
</nav>
</header>
<main class="impressum-container" role="main">
<article>
<h1>Impressum</h1>
<section class="impressum-section" aria-labelledby="tmg-heading">
<h2 id="tmg-heading">Angaben gemäß § 5 TMG</h2>
<address>
<strong>Reifen Späth – Kfz-Meisterbetrieb</strong><br />
Inhaber: <strong>Daniel Späth</strong><br />
Sandizeller Straße 10C<br />
86571 Langenmosen<br />
Deutschland
</address>
<p class="contact-info">
Telefon: <a href="tel:+4915568569616">+49 (0)155 68569616</a><br />
E-Mail: <a href="mailto:info@reifenspaeth.de">info@reifenspaeth.de</a><br />
Website: <a href="https://www.reifenspaeth.de" rel="canonical">www.reifenspaeth.de</a>
</p>
</section>
<section class="impressum-section" aria-labelledby="beruf-heading">
<h2 id="beruf-heading">Berufsbezeichnung und berufsrechtliche Angaben</h2>
<dl>
<dt>Berufsbezeichnung:</dt>
<dd>Kfz-Meister</dd>
<dt>Zuständige Kammer:</dt>
<dd>Handwerkskammer für München und Oberbayern</dd>
<dt>Verliehen in:</dt>
<dd>Deutschland</dd>
</dl>
</section>
<section class="impressum-section" aria-labelledby="haftung-heading">
<h2 id="haftung-heading">Haftungsausschluss (Disclaimer)</h2>
<h3>Haftung für Inhalte</h3>
<p>
Als Diensteanbieter bin ich gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich.
Nach §§ 8 bis 10 TMG bin ich als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen
oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt.
Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich.
Bei Bekanntwerden entsprechender Rechtsverletzungen werde ich diese Inhalte umgehend entfernen.
</p>
<h3>Haftung für Links</h3>
<p>
Mein Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte ich keinen Einfluss habe.
Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen.
Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.
Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft.
Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.
Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.
Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
</p>
<h3>Urheberrecht</h3>
<p>
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht.
Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts
bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet.
Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitte ich um einen entsprechenden Hinweis.
Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
</p>
</section>
<section class="impressum-section" aria-labelledby="mstv-heading">
<h2 id="mstv-heading">Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<address>
Daniel Späth<br />
Sandizeller Straße 10C<br />
86571 Langenmosen
</address>
</section>
</article>
<footer class="impressum-footer">
<p><small>© 2025 Reifen Späth – Kfz-Meisterbetrieb. Alle Rechte vorbehalten.</small></p>
<nav aria-label="Fußzeilen-Navigation">
<a href="/" class="footer-back-link">Zurück zur Startseite</a>
</nav>
</footer>
</main>
</div>
@@ -0,0 +1,157 @@
/* ========================================
Impressum (Imprint) Page - Scoped Styles
========================================
Inherits from app.css: design tokens, btn styles.
======================================== */
.impressum-page {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-bg-gradient);
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
}
.impressum-nav {
padding: var(--space-6);
border-bottom: 1px solid var(--border-light);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-primary-light);
text-decoration: none;
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
transition: all var(--duration-fast) var(--ease-out);
}
.back-link:hover {
color: var(--color-primary);
text-decoration: none;
transform: translateX(-4px);
}
.back-link svg {
transition: transform var(--duration-fast) var(--ease-out);
}
.back-link:hover svg {
transform: translateX(-4px);
}
.impressum-container {
max-width: 900px;
margin: 0 auto;
padding: var(--space-10) var(--space-6);
}
.impressum-container h1 {
font-family: var(--font-family-heading);
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin-bottom: var(--space-10);
background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.impressum-section {
margin-bottom: var(--space-10);
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--border-light);
}
.impressum-section:last-of-type {
border-bottom: none;
}
.impressum-section h2 {
font-family: var(--font-family-heading);
font-size: clamp(1.25rem, 3vw, 1.5rem);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-5);
}
.impressum-section h3 {
font-family: var(--font-family-base);
font-size: var(--text-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-primary-light);
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
.impressum-section p {
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.impressum-section strong {
color: var(--text-primary);
}
.impressum-section a {
color: var(--color-primary-light);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
.impressum-section a:hover {
color: var(--color-primary);
text-decoration: underline;
}
.contact-info {
margin-top: var(--space-6);
padding: var(--space-4);
background: var(--surface-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.impressum-footer {
margin-top: var(--space-12);
padding-top: var(--space-8);
border-top: 1px solid var(--border-default);
text-align: center;
}
.impressum-footer p {
color: var(--text-muted);
font-family: var(--font-family-base);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.footer-back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-primary-light);
text-decoration: none;
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
padding: var(--space-3) var(--space-6);
border: 1px solid var(--color-primary);
border-radius: var(--radius-md);
transition: all var(--duration-normal) var(--ease-out);
}
.footer-back-link:hover {
background: var(--color-primary);
color: var(--text-primary);
text-decoration: none;
box-shadow: 0 0 20px rgba(220, 38, 38, 0.5);
}
@media (max-width: 768px) {
.impressum-container {
padding: var(--space-8) var(--space-4);
}
}
+398
View File
@@ -0,0 +1,398 @@
@page "/login"
@layout EmptyLayout
@inject IAuthService AuthService
@inject ITwoFactorService TwoFactorService
@inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Login - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Login - Reifen Späth Lagerverwaltung"
Description="Anmeldung zur Reifen Späth Lagerverwaltung. Zugang für Mitarbeiter und Administratoren."
Robots="noindex, nofollow"
CanonicalUrl="https://www.reifenspaeth.de/login" />
<div class="login-page">
<main class="login-container" role="main">
<header class="login-header">
<h1 class="login-brand">REIFEN SPÄTH</h1>
<p class="login-subtitle">Lagerverwaltung</p>
</header>
<section class="login-card" aria-labelledby="login-title">
@if (!showTwoFactorStep)
{
<h2 id="login-title" class="login-title">Anmelden</h2>
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
<div class="form-group">
<label class="form-label" for="username">Benutzername</label>
<input type="text"
id="username"
class="form-control @(!string.IsNullOrEmpty(errorMessage) ? "is-invalid" : "")"
@bind="username"
@bind:event="oninput"
@onfocus="ClearError"
placeholder="Benutzername eingeben"
autocomplete="username"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
disabled="@isLoading"
maxlength="50"
required
aria-required="true"
aria-invalid="@(!string.IsNullOrEmpty(errorMessage))"
aria-describedby="@(!string.IsNullOrEmpty(errorMessage) ? "login-error" : null)" />
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input type="password"
id="password"
class="form-control @(!string.IsNullOrEmpty(errorMessage) ? "is-invalid" : "")"
@bind="password"
@bind:event="oninput"
@onfocus="ClearError"
placeholder="Passwort eingeben"
autocomplete="current-password"
disabled="@isLoading"
required
aria-required="true"
aria-invalid="@(!string.IsNullOrEmpty(errorMessage))"
aria-describedby="@(!string.IsNullOrEmpty(errorMessage) ? "login-error" : null)" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div id="login-error" class="alert alert-danger" role="alert" aria-live="assertive">
<svg width="16" height="16" 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>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>@errorMessage</span>
</div>
}
<button type="submit"
class="btn btn-login"
disabled="@(isLoading || !CanLogin)"
aria-busy="@isLoading">
@if (isLoading)
{
<span class="spinner-sm" aria-hidden="true"></span>
<span>Anmelden...</span>
}
else
{
<span>Anmelden</span>
}
</button>
</form>
}
else
{
@* 2FA Code Entry Step *@
<h2 id="login-title" class="login-title">Zwei-Faktor-Authentifizierung</h2>
<p class="tfa-description">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.</p>
<form @onsubmit="HandleTwoFactorSubmit" @onsubmit:preventDefault>
<div class="form-group">
<label class="form-label" for="tfa-code">Authentifizierungscode</label>
<input type="text"
id="tfa-code"
class="form-control tfa-code-input @(!string.IsNullOrEmpty(errorMessage) ? "is-invalid" : "")"
@bind="twoFactorCode"
@bind:event="oninput"
@onfocus="ClearError"
placeholder="000000"
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
disabled="@isLoading"
required
aria-required="true" />
</div>
@if (emailFallbackAvailable)
{
<div class="tfa-fallback">
<button type="button" class="btn-link" @onclick="SendEmailFallbackCode" disabled="@isSendingEmail">
@if (isSendingEmail)
{
<span class="spinner-sm" aria-hidden="true"></span>
}
Code per E-Mail senden
</button>
@if (!string.IsNullOrEmpty(emailFallbackMessage))
{
<span class="tfa-email-status @emailFallbackClass">@emailFallbackMessage</span>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div id="login-error" class="alert alert-danger" role="alert" aria-live="assertive">
<svg width="16" height="16" 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>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>@errorMessage</span>
</div>
}
<button type="submit"
class="btn btn-login"
disabled="@(isLoading || twoFactorCode.Length != 6)"
aria-busy="@isLoading">
@if (isLoading)
{
<span class="spinner-sm" aria-hidden="true"></span>
<span>Überprüfen...</span>
}
else
{
<span>Bestätigen</span>
}
</button>
<button type="button" class="btn btn-back" @onclick="BackToLogin">
? Zurück
</button>
</form>
}
<footer class="login-footer">
<a href="/" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Zurück zur Startseite
</a>
</footer>
</section>
</main>
</div>
@code {
private string username = "";
private string password = "";
private bool isLoading;
private string? errorMessage;
// 2FA state
private bool showTwoFactorStep;
private string twoFactorCode = "";
private AppUser? pendingUser;
private bool emailFallbackAvailable;
private bool isSendingEmail;
private string? emailFallbackMessage;
private string emailFallbackClass = "";
private string? pendingEmailCode;
private DateTime? emailCodeExpiry;
private bool CanLogin =>
!string.IsNullOrWhiteSpace(username) &&
!string.IsNullOrWhiteSpace(password);
protected override void OnInitialized()
{
if (UserSession.IsAuthenticated)
{
Navigation.NavigateTo("/tire-storage");
}
}
private void ClearError()
{
if (!string.IsNullOrEmpty(errorMessage))
{
errorMessage = null;
StateHasChanged();
}
}
private async Task HandleLogin()
{
if (isLoading || !CanLogin) return;
errorMessage = null;
isLoading = true;
StateHasChanged();
try
{
await Task.Delay(250);
var user = await AuthService.LoginAsync(username.Trim(), password);
if (user != null)
{
// Check if 2FA is required
var tfSettings = await SiteSettingsService.GetTwoFactorSettingsAsync();
if (tfSettings.IsEnabled && user.TwoFactorEnabled && !string.IsNullOrEmpty(user.TwoFactorSecret))
{
pendingUser = user;
showTwoFactorStep = true;
twoFactorCode = "";
emailFallbackAvailable = tfSettings.EmailFallbackEnabled;
emailFallbackMessage = null;
pendingEmailCode = null;
}
else
{
await CompleteLogin(user);
}
}
else
{
errorMessage = "Ungültiger Benutzername oder Passwort.";
password = "";
try
{
await JS.InvokeVoidAsync("qrScanner.playSound", "error");
}
catch { }
}
}
catch (Exception)
{
errorMessage = "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task HandleTwoFactorSubmit()
{
if (isLoading || pendingUser == null || twoFactorCode.Length != 6) return;
errorMessage = null;
isLoading = true;
StateHasChanged();
try
{
await Task.Delay(200);
bool valid = false;
// Check email fallback code first
if (pendingEmailCode != null && emailCodeExpiry.HasValue && DateTime.UtcNow < emailCodeExpiry.Value)
{
if (System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(pendingEmailCode),
System.Text.Encoding.UTF8.GetBytes(twoFactorCode)))
{
valid = true;
}
}
// Check TOTP code
if (!valid && !string.IsNullOrEmpty(pendingUser.TwoFactorSecret))
{
valid = TwoFactorService.ValidateTotpCode(pendingUser.TwoFactorSecret, twoFactorCode);
}
if (valid)
{
await CompleteLogin(pendingUser);
}
else
{
errorMessage = "Ungültiger Code. Bitte versuchen Sie es erneut.";
twoFactorCode = "";
try { await JS.InvokeVoidAsync("qrScanner.playSound", "error"); } catch { }
}
}
catch (Exception)
{
errorMessage = "Ein Fehler ist aufgetreten.";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task SendEmailFallbackCode()
{
if (pendingUser == null || isSendingEmail) return;
isSendingEmail = true;
emailFallbackMessage = null;
StateHasChanged();
try
{
var smtpSettings = await SiteSettingsService.GetSmtpSettingsAsync();
if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured)
{
emailFallbackMessage = "E-Mail-Versand nicht konfiguriert.";
emailFallbackClass = "text-danger";
return;
}
var code = TwoFactorService.GenerateEmailCode();
var sent = await TwoFactorService.SendEmailCodeAsync(smtpSettings.FromEmail, code, pendingUser.Username);
if (sent)
{
pendingEmailCode = code;
emailCodeExpiry = DateTime.UtcNow.AddMinutes(5);
emailFallbackMessage = "Code wurde gesendet.";
emailFallbackClass = "text-success";
}
else
{
emailFallbackMessage = "Fehler beim E-Mail-Versand.";
emailFallbackClass = "text-danger";
}
}
catch
{
emailFallbackMessage = "Fehler beim E-Mail-Versand.";
emailFallbackClass = "text-danger";
}
finally
{
isSendingEmail = false;
StateHasChanged();
}
}
private async Task CompleteLogin(AppUser user)
{
try
{
await JS.InvokeVoidAsync("localStorage.setItem", "userId", user.Id.ToString());
}
catch { }
UserSession.SetUser(user);
Navigation.NavigateTo("/tire-storage");
}
private void BackToLogin()
{
showTwoFactorStep = false;
twoFactorCode = "";
pendingUser = null;
errorMessage = null;
password = "";
pendingEmailCode = null;
emailCodeExpiry = null;
emailFallbackMessage = null;
}
}
@@ -0,0 +1,263 @@
/* ========================================
Login Page - Scoped Component Styles
========================================
Inherits from app.css:
- .form-control, .form-label (section 4)
- .alert, .alert-danger/success (section 7)
- .btn, .btn-primary (section 5)
- .spinner-border, @keyframes spin (section 11)
- Reduced motion (section 17)
Only page-specific additions and overrides below.
======================================== */
/* ========================================
Page Container
======================================== */
.login-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-gradient);
padding: var(--space-6);
}
.login-container {
width: 100%;
max-width: 360px;
}
/* ========================================
Header
======================================== */
.login-header {
text-align: center;
margin-bottom: var(--space-8);
}
.login-brand {
font-family: var(--font-family-heading);
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0;
}
.login-subtitle {
font-family: var(--font-family-base);
color: var(--text-placeholder);
margin-top: var(--space-1);
font-size: var(--text-base);
}
/* ========================================
Login Card
======================================== */
.login-card {
background: var(--surface-elevated);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-xl);
padding: var(--space-8);
box-shadow: var(--shadow-xl);
}
.login-title {
font-family: var(--font-family-base);
font-size: var(--text-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
text-align: center;
margin-bottom: var(--space-6);
}
/* ========================================
Form Groups (page-specific spacing)
======================================== */
.form-group {
margin-bottom: var(--space-5);
}
.form-label {
font-family: var(--font-family-base);
}
.form-control:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-control.is-invalid {
border-color: rgba(220, 38, 38, 0.5);
}
/* ========================================
Login Button
======================================== */
.btn-login {
width: 100%;
padding: var(--space-4);
background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: var(--text-base);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-spring);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
-webkit-tap-highlight-color: transparent;
}
.btn-login:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(220, 38, 38, 0.4);
transform: translateY(-1px);
}
.btn-login:active:not(:disabled) {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
}
.btn-login:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
/* ========================================
Footer
======================================== */
.login-footer {
text-align: center;
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 0.5px solid var(--border-light);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-family-base);
color: var(--text-placeholder);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-spring);
}
.back-link:hover {
color: var(--color-primary);
text-decoration: none;
}
.back-link svg {
transition: transform var(--duration-fast) var(--ease-out);
}
.back-link:hover svg {
transform: translateX(-3px);
}
/* ========================================
Loading Spinner (inline, page-specific)
======================================== */
.spinner-sm {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--text-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ========================================
Alert (page-specific override — tighter spacing)
======================================== */
.alert {
margin-bottom: var(--space-5);
}
.alert svg {
flex-shrink: 0;
}
/* ========================================
Two-Factor Authentication Step
======================================== */
.tfa-description {
color: var(--text-secondary);
font-family: var(--font-family-base);
font-size: var(--text-sm);
margin-bottom: var(--space-6);
text-align: center;
}
.tfa-code-input {
font-size: var(--text-2xl);
letter-spacing: 0.4em;
text-align: center;
font-family: 'Courier New', monospace;
font-weight: var(--font-weight-bold);
}
.tfa-fallback {
text-align: center;
margin: var(--space-3) 0 var(--space-4);
}
.tfa-fallback .btn-link {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-family: var(--font-family-base);
font-size: var(--text-sm);
text-decoration: underline;
padding: var(--space-1) var(--space-2);
}
.tfa-fallback .btn-link:hover {
color: var(--text-secondary);
}
.tfa-email-status {
display: block;
font-family: var(--font-family-base);
font-size: var(--text-xs);
margin-top: var(--space-1);
}
.tfa-email-status.text-success {
color: var(--color-success);
}
.tfa-email-status.text-danger {
color: var(--color-error);
}
.btn-back {
display: block;
width: 100%;
background: none;
border: 1px solid var(--border-default);
color: var(--text-secondary);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-top: var(--space-3);
cursor: pointer;
font-family: var(--font-family-base);
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
}
.btn-back:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,953 @@
/* ========================================
Status Page - Scoped Component Styles
========================================
Inherits from app.css:
- .access-denied, .empty-icon, .access-denied-icon (section 19)
- .alert, .alert-danger/success/warning (section 7)
- .form-control, .form-select, .form-label (section 4)
- .btn, .btn-primary/secondary/danger, .btn-sm, .btn-close (section 5)
- .spinner-border, @keyframes spin (section 11)
- Reduced motion (section 17)
Only page-specific additions and overrides below.
======================================== */
/* ========================================
Page Container
======================================== */
.status-container {
padding: var(--space-6) var(--space-page-x);
max-width: 1600px;
margin: 0 auto;
}
@media (min-width: 1024px) {
.status-container {
padding: var(--space-8) var(--space-12);
}
}
@media (min-width: 1440px) {
.status-container {
padding: var(--space-10) 4rem;
}
}
/* ========================================
Header
======================================== */
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-8);
gap: var(--space-6);
}
.header-content h1 {
margin: 0 0 var(--space-2) 0;
font-size: clamp(1.5rem, 3vw, 2rem);
color: var(--text-primary);
font-family: var(--font-family-heading);
letter-spacing: -0.02em;
}
.header-subtitle {
margin: 0;
color: var(--text-tertiary);
font-size: var(--text-base);
font-family: var(--font-family-base);
}
/* ========================================
Status Grid
======================================== */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: var(--space-5);
}
@media (min-width: 1200px) {
.status-grid {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
}
}
@media (min-width: 1600px) {
.status-grid {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-8);
}
}
.status-card {
background: var(--surface-elevated);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-xl);
overflow: hidden;
transition: all var(--duration-fast) var(--ease-out);
box-shadow: var(--shadow-md);
}
.status-card:hover {
transform: translateY(-3px);
border-color: rgba(220, 38, 38, 0.4);
box-shadow: var(--shadow-xl);
}
.status-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-5) var(--space-6);
background: rgba(0, 0, 0, 0.25);
border-bottom: 0.5px solid var(--border-light);
}
.card-title {
display: flex;
align-items: center;
gap: var(--space-3);
}
.card-title svg {
color: var(--color-primary-light);
width: 20px;
height: 20px;
}
.card-title h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
font-family: var(--font-family-base);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ========================================
Status Badges
======================================== */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
font-family: var(--font-family-base);
}
.status-badge.status-ok {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success-light);
}
.status-badge.status-error {
background: rgba(220, 38, 38, 0.15);
color: var(--color-error-light);
}
.status-badge.status-warning {
background: rgba(234, 179, 8, 0.15);
color: var(--color-warning-light);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ========================================
Card Body & Status Items
======================================== */
.status-card-body {
padding: var(--space-4) var(--space-6) var(--space-5);
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 0.5px solid var(--border-light);
}
.status-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.status-item .label {
color: var(--text-tertiary);
font-size: var(--text-sm);
font-family: var(--font-family-base);
}
.status-item .value {
color: var(--text-primary);
font-weight: var(--font-weight-medium);
font-size: var(--text-sm);
text-align: right;
max-width: 55%;
word-break: break-all;
font-family: var(--font-family-base);
}
.status-item .value.mono {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.status-item .value.small {
font-size: var(--text-xs);
}
.status-item .value.highlight {
color: var(--color-error-light);
font-weight: var(--font-weight-semibold);
}
.status-item .value.text-success {
color: var(--color-success-light);
}
.status-item .value.text-danger {
color: var(--color-error-light);
}
.status-item .value .text-success {
color: var(--color-success-light);
}
.status-item .value .text-danger {
color: var(--color-error-light);
}
.status-item .value .text-muted {
color: var(--text-placeholder);
}
.status-divider {
height: 1px;
background: var(--border-light);
margin: var(--space-1) 0;
}
/* ========================================
Environment & Role Badges
======================================== */
.env-badge {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
font-family: var(--font-family-base);
}
.env-badge.dev {
background: rgba(59, 130, 246, 0.15);
color: var(--color-info);
}
.env-badge.prod {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success-light);
}
.role-badge {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
background: rgba(220, 38, 38, 0.15);
color: var(--color-error-light);
font-family: var(--font-family-base);
}
/* ========================================
Stats Grid
======================================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
text-align: center;
margin-bottom: var(--space-4);
}
.stat-item {
padding: var(--space-5) var(--space-3);
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-lg);
border: 0.5px solid var(--border-light);
transition: all var(--duration-fast) var(--ease-default);
}
.stat-item:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(220, 38, 38, 0.2);
}
.stat-value {
display: block;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: var(--font-weight-bold);
color: var(--color-error-light);
font-family: var(--font-family-heading);
line-height: var(--line-height-tight);
}
.stat-label {
display: block;
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-2);
font-family: var(--font-family-base);
}
/* Stats Detail */
.stats-detail {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 0.5px solid var(--border-light);
}
.stats-detail-item {
display: flex;
justify-content: space-between;
font-family: var(--font-family-base);
font-size: var(--text-sm);
}
.stats-detail-item .label {
color: var(--text-tertiary);
}
.stats-detail-item .value {
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
/* ========================================
Quick Actions
======================================== */
.quick-actions {
margin-top: var(--space-10);
padding-top: var(--space-8);
border-top: 0.5px solid var(--border-light);
}
.quick-actions h3 {
margin: 0 0 var(--space-5) 0;
font-family: var(--font-family-base);
font-size: var(--text-base);
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-4);
}
@media (min-width: 1024px) {
.actions-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-6);
background: var(--surface-elevated);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-xl);
color: var(--text-secondary);
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
font-size: var(--text-base);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
text-decoration: none;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(220, 38, 38, 0.4);
color: var(--text-primary);
text-decoration: none;
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-btn:active {
transform: scale(0.98);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.action-btn svg {
color: var(--color-primary-light);
flex-shrink: 0;
width: 22px;
height: 22px;
}
/* Action Message */
.action-message {
margin-top: var(--space-6);
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-lg);
font-family: var(--font-family-base);
font-size: var(--text-base);
text-align: center;
}
.action-message.success {
background: rgba(34, 197, 94, 0.12);
border: 0.5px solid rgba(34, 197, 94, 0.25);
color: var(--color-success-light);
}
.action-message.error {
background: rgba(220, 38, 38, 0.12);
border: 0.5px solid rgba(220, 38, 38, 0.25);
color: var(--color-error-light);
}
/* ========================================
Loading & Auth States
======================================== */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12) var(--space-8);
color: var(--text-tertiary);
}
.loading-state .spinner-border {
width: 3rem;
height: 3rem;
margin-bottom: var(--space-6);
color: var(--color-primary-light);
}
.auth-required {
text-align: center;
padding: var(--space-10) var(--space-8);
background: var(--surface-elevated);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-2xl);
max-width: 420px;
margin: var(--space-12) auto;
}
.auth-icon {
font-size: 3rem;
margin-bottom: var(--space-5);
}
.auth-required h2 {
margin: 0 0 var(--space-3) 0;
color: var(--text-primary);
font-family: var(--font-family-base);
font-size: var(--text-2xl);
}
.auth-required p {
color: var(--text-tertiary);
margin-bottom: var(--space-8);
font-family: var(--font-family-base);
font-size: var(--text-base);
}
/* ========================================
Footer
======================================== */
.status-footer {
text-align: center;
margin-top: var(--space-10);
padding-top: var(--space-6);
border-top: 0.5px solid var(--border-light);
}
.status-footer p {
margin: 0;
color: var(--text-placeholder);
font-size: var(--text-sm);
font-family: var(--font-family-base);
}
/* ========================================
Responsive - Mobile
======================================== */
@media (max-width: 768px) {
.status-container {
padding: var(--space-4) var(--space-3);
padding-bottom: calc(var(--space-4) + var(--safe-area-bottom));
}
.status-header {
flex-direction: column;
align-items: stretch;
gap: var(--space-4);
}
.status-header .btn {
width: 100%;
justify-content: center;
}
.status-grid {
grid-template-columns: 1fr;
}
.status-card {
border-radius: var(--radius-xl);
}
.status-card-header {
padding: var(--space-4);
}
.status-card-body {
padding: var(--space-4);
}
.status-item {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-3) 0;
}
.status-item .value {
text-align: left;
max-width: 100%;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.stat-item {
padding: var(--space-4) var(--space-3);
}
.stat-value {
font-size: 1.25rem;
}
.actions-grid {
grid-template-columns: 1fr;
}
.action-btn {
padding: var(--space-4);
}
}
/* ========================================
Banner Settings Panel (Gold Theme)
======================================== */
.banner-settings-panel {
margin-top: var(--space-8);
background: var(--surface-primary);
border: 1px solid rgba(218, 165, 32, 0.3);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 8px 32px rgba(218, 165, 32, 0.1);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-5) var(--space-6);
background: linear-gradient(135deg, rgba(218, 165, 32, 0.15) 0%, rgba(184, 134, 11, 0.1) 100%);
border-bottom: 1px solid rgba(218, 165, 32, 0.2);
}
.panel-header h3 {
margin: 0;
font-family: var(--font-family-base);
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
color: #daa520;
}
.panel-header .btn-close {
background: rgba(255, 255, 255, 0.1);
border: none;
color: var(--text-tertiary);
width: 32px;
height: 32px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-base);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast) var(--ease-default);
}
.panel-header .btn-close:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.panel-body {
padding: var(--space-6);
}
.panel-body .form-group {
margin-bottom: var(--space-5);
}
.panel-body .form-group label {
display: block;
margin-bottom: var(--space-2);
font-family: var(--font-family-base);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.panel-body .form-control {
width: 100%;
padding: var(--space-3) var(--space-4);
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-lg);
color: var(--text-primary);
-webkit-text-fill-color: var(--text-primary);
font-family: var(--font-family-base);
font-size: var(--text-base);
transition: all var(--duration-fast) var(--ease-default);
}
.panel-body .form-control:focus {
outline: none;
border-color: #daa520;
box-shadow: 0 0 0 3px rgba(218, 165, 32, 0.15);
color: var(--text-primary);
-webkit-text-fill-color: var(--text-primary);
}
.panel-body .form-control:-webkit-autofill,
.panel-body .form-control:-webkit-autofill:hover,
.panel-body .form-control:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text-primary);
-webkit-box-shadow: 0 0 0 1000px rgba(0, 0, 0, 0.3) inset;
transition: background-color 5000s ease-in-out 0s;
}
.panel-body .form-control::placeholder {
color: var(--text-placeholder);
}
.form-hint {
display: block;
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-placeholder);
font-family: var(--font-family-base);
}
.panel-body textarea.form-control {
resize: vertical;
min-height: 100px;
}
.panel-body .form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
@media (max-width: 640px) {
.panel-body .form-row {
grid-template-columns: 1fr;
}
}
/* ========================================
Toggle Switch
======================================== */
.toggle-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.toggle-label-text {
font-family: var(--font-family-base);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.toggle-container {
display: flex;
align-items: center;
cursor: pointer;
}
.toggle-container input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch {
position: relative;
flex-shrink: 0;
width: 48px;
height: 26px;
background: rgba(255, 255, 255, 0.15);
border-radius: 13px;
transition: all var(--duration-normal) var(--ease-default);
}
.toggle-switch::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: var(--text-primary);
border-radius: 50%;
transition: all var(--duration-normal) var(--ease-default);
}
.toggle-container input[type="checkbox"]:checked + .toggle-switch {
background: #daa520;
}
.toggle-container input[type="checkbox"]:checked + .toggle-switch::after {
transform: translateX(22px);
}
/* ========================================
Panel Actions (Gold Theme)
======================================== */
.panel-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--border-light);
}
.panel-actions .btn {
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-lg);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.panel-actions .btn-outline {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-secondary);
}
.panel-actions .btn-outline:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.3);
color: var(--text-primary);
}
.panel-actions .btn-primary {
background: linear-gradient(135deg, #daa520 0%, #b8860b 100%);
border: none;
color: #000;
}
.panel-actions .btn-primary:hover {
box-shadow: 0 4px 16px rgba(218, 165, 32, 0.4);
transform: translateY(-1px);
}
.panel-actions .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ========================================
Banner Message
======================================== */
.banner-message {
margin-top: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-family: var(--font-family-base);
font-size: var(--text-sm);
text-align: center;
}
.banner-message.success {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
color: var(--color-success-light);
}
.banner-message.error {
background: rgba(220, 38, 38, 0.12);
border: 1px solid rgba(220, 38, 38, 0.25);
color: var(--color-error-light);
}
/* ========================================
Banner Status Info
======================================== */
.banner-status-info {
margin: var(--space-4) 0;
padding: var(--space-3) var(--space-4);
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-lg);
text-align: center;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-family-base);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
}
.status-indicator.active {
color: var(--color-success-light);
}
.status-indicator.inactive {
color: var(--text-tertiary);
}
/* ========================================
Status Icons
======================================== */
.status-icon {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
}
.status-icon.active-icon {
background: var(--color-success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-icon.active-icon::after {
content: '';
display: block;
width: 100%;
height: 100%;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E") center/10px no-repeat;
}
.status-icon.disabled-icon {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
}
.status-icon.scheduled-icon {
background: var(--color-info);
box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
}
.status-icon.scheduled-icon::after {
content: '';
display: block;
width: 100%;
height: 100%;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 6v6l4 2'/%3E%3C/svg%3E") center/12px no-repeat;
}
.status-icon.expired-icon {
background: #6b7280;
}
.status-icon.expired-icon::after {
content: '';
display: block;
width: 100%;
height: 100%;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Crect x='6' y='6' width='12' height='12'/%3E%3C/svg%3E") center/10px no-repeat;
}
.status-icon.warning-icon {
background: var(--color-warning);
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
}
.status-icon.warning-icon::after {
content: '!';
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 12px;
font-weight: bold;
color: white;
}
/* ========================================
Panel Section Titles
======================================== */
.panel-section-title {
font-size: var(--text-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin: var(--space-2) 0 var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-light);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,444 @@
@page "/tire-checkout"
@using Lagerbestand.Components.Shared
@implements IDisposable
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject UserSessionService UserSession
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Reifenstapel Abholen - Reifen Späth Lagerverwaltung"
Description="Reifen abholen in der Reifen Späth Lagerverwaltung. Schnelle Abwicklung von Kundenabholungen."
Robots="noindex, nofollow" />
@if (!UserSession.IsWorkerOrAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Mitarbeiter und Administratoren zugänglich.</p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
</section>
}
else
{
<main class="checkout-page" role="main">
<header class="page-header">
<h1>Reifenstapel Abholen</h1>
<p>Suchen Sie nach Ticket-Nummer, Kundenname, Fahrzeug oder Lagerplatz-Code</p>
</header>
@* Search Section *@
<section class="checkout-card" aria-labelledby="search-heading">
<h2 id="search-heading" class="visually-hidden">Suche</h2>
<div class="search-section">
<label for="search-input" class="visually-hidden">Suche nach Ticket-Nr., Kunde, Fahrzeug oder Lagerplatz</label>
<input type="search"
id="search-input"
class="form-control search-input"
placeholder="Ticket-Nr., Kunde, Fahrzeug oder Lagerplatz (z.B. 2135)..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="SearchTireStacks" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert" aria-live="assertive">@errorMessage</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success" role="status" aria-live="polite">@successMessage</div>
}
</section>
@* Scanned Items List *@
@if (scannedTireStacks.Count > 0)
{
<div class="scanned-section" aria-labelledby="scanned-items-heading">
<div class="scanned-header">
<h2 id="scanned-items-heading">
<span class="scan-count">@scannedTireStacks.Count</span>
Reifenstapel zum Abholen
</h2>
<button class="btn btn-sm btn-outline-danger" @onclick="ClearAllScanned">
Alle entfernen
</button>
</div>
<div class="scanned-summary" role="complementary">
<div class="summary-item">
<span class="summary-value">@scannedTireStacks.Count</span>
<span class="summary-label">Stapel</span>
</div>
<div class="summary-item">
<span class="summary-value">@(scannedTireStacks.Count * TireStack.TiresPerStack)</span>
<span class="summary-label">Reifen gesamt</span>
</div>
<div class="summary-item">
<span class="summary-value">@scannedTireStacks.Select(ts => ts.StorageLocation?.Code).Distinct().Count()</span>
<span class="summary-label">Lagerplätze</span>
</div>
</div>
<div class="scanned-list" role="list">
@foreach (var ts in scannedTireStacks)
{
<div class="scanned-item @(ts == lastAddedTireStack ? "highlight" : "")" role="listitem">
<div class="scanned-item-header">
<div class="scanned-item-main">
<span class="ticket-number">@ts.TicketNumber</span>
@if (ts.StorageLocation != null)
{
<LocationDisplay Code="@ts.StorageLocation.Code"
DisplayMode="LocationDisplayMode.Compact"
Size="LocationDisplaySize.Small" />
}
else
{
<span class="location-badge">📍 -</span>
}
</div>
<button class="btn-remove" @onclick="() => RemoveScannedItem(ts)" title="Entfernen" aria-label="Entfernen von @ts.TicketNumber">✕</button>
</div>
<div class="scanned-item-body">
<div class="scanned-customer">
@if (ts.Customer != null)
{
<span class="customer-name">@ts.Customer.FullName</span>
@if (ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
}
}
else if (ts.CatalogItem != null)
{
<span class="customer-name inventory">📦 @ts.CatalogItem.Name</span>
}
else
{
<span class="customer-name inventory">📦 Lagerbestand</span>
}
</div>
<div class="scanned-details">
<span class="tire-count">1 Stapel (@TireStack.TiresPerStack Reifen)</span>
<span class="tire-season">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</span>
@if (!string.IsNullOrEmpty(ts.TireSize))
{
<span class="tire-size">@ts.TireSize</span>
}
@if (ts.WithRims)
{
<span class="with-rims">mit Felgen</span>
}
</div>
@if (!string.IsNullOrEmpty(ts.TireBrand))
{
<div class="scanned-brand">
@ts.TireBrand @ts.TireModel
</div>
}
<div class="scanned-meta">
<span class="stored-date">Eingelagert: @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</span>
<span class="days-stored">(@ts.DaysStored Tage)</span>
</div>
</div>
</div>
}
</div>
<div class="scanned-actions">
<button class="btn btn-primary btn-lg w-100"
@onclick="ProcessBatchPickup"
disabled="@(isProcessing || scannedTireStacks.Count == 0)">
@if (isProcessing)
{
<span class="spinner"></span>
}
<span>@scannedTireStacks.Count Stapel abholen (@(scannedTireStacks.Count * TireStack.TiresPerStack) Reifen)</span>
</button>
</div>
</div>
}
@* Search Results *@
@if (searchResults.Count > 0)
{
<div class="results-section" aria-labelledby="search-results-heading">
<h2 id="search-results-heading" class="results-count">
@searchResults.Count Ergebnis(se)
</h2>
<div class="tireset-list" role="list">
@foreach (var ts in searchResults)
{
var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id);
<div class="tireset-card @(isAlreadyScanned ? "already-scanned" : "")"
@onclick="() => AddToScannedList(ts)" role="listitem">
<div class="tireset-header">
<span class="ticket-number">@ts.TicketNumber</span>
<span class="status-badge @ts.Status.ToString().ToLower()">
@if (isAlreadyScanned)
{
<span>✓ Ausgewählt</span>
}
else
{
@ts.StatusText
}
</span>
</div>
<div class="tireset-body">
<div class="customer-info">
@if (ts.Customer != null)
{
<span class="customer-name">@ts.Customer.FullName</span>
@if (ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
}
}
else
{
<span class="customer-name">📦 @(ts.CatalogItem?.Name ?? "Lagerbestand")</span>
}
</div>
<div class="tire-info">
<span class="tire-count">1 Stapel (@TireStack.TiresPerStack Reifen)</span>
<span class="tire-details">@ts.Season @(ts.TireSize != null ? $"| {ts.TireSize}" : "")</span>
</div>
<div class="location-info">
@if (ts.StorageLocation != null)
{
<LocationDisplay Code="@ts.StorageLocation.Code"
DisplayMode="LocationDisplayMode.Full"
Size="LocationDisplaySize.Small" />
}
else
{
<span class="location">📍 Kein Lagerplatz</span>
}
<span class="stored-date">Seit @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</span>
</div>
</div>
</div>
}
</div>
</div>
}
@* Success Modal *@
@if (showSuccess)
{
<div class="success-overlay" @onclick="CloseSuccess">
<div class="success-modal" @onclick:stopPropagation="true">
<div class="success-icon">✓</div>
<h2>Abholung erfolgreich!</h2>
@if (pickedUpTireStacks.Count == 1)
{
<p><strong>@pickedUpTireStacks[0].TicketNumber</strong> wurde abgeholt.</p>
<p class="pickup-detail">1 Stapel (@TireStack.TiresPerStack Reifen) abgeholt</p>
}
else
{
<p><strong>@pickedUpTireStacks.Count Reifenstapel</strong> wurden abgeholt.</p>
<p class="pickup-detail">@(pickedUpTireStacks.Count * TireStack.TiresPerStack) Reifen insgesamt</p>
<div class="pickup-list" role="list">
@foreach (var ts in pickedUpTireStacks)
{
<div class="pickup-item" role="listitem">
<span class="ticket">@ts.TicketNumber</span>
<span class="info">1 Stapel | @(ts.Customer?.FullName ?? ts.CatalogItem?.Name ?? "Lagerbestand")</span>
</div>
}
</div>
}
<p class="success-detail">
@if (freedLocations.Count == 1)
{
<span>Lagerplatz <strong>@freedLocations[0]</strong> ist wieder frei.</span>
}
else if (freedLocations.Count > 1)
{
<span><strong>@freedLocations.Count Lagerplätze</strong> sind wieder frei.</span>
}
</p>
<button class="btn btn-primary" @onclick="CloseSuccess">OK</button>
</div>
</div>
}
</main>
}
@code {
private bool isProcessing;
private bool showSuccess;
private string searchTerm = "";
private string? errorMessage;
private string? successMessage;
private List<TireStack> searchResults = new();
private List<TireStack> scannedTireStacks = new();
private List<TireStack> pickedUpTireStacks = new();
private List<string> freedLocations = new();
private TireStack? lastAddedTireStack;
private CancellationTokenSource? _searchDebounceCts;
private readonly SemaphoreSlim _searchLock = new(1, 1);
private async Task SearchTireStacks()
{
if (string.IsNullOrWhiteSpace(searchTerm) || searchTerm.Length < 2)
{
searchResults.Clear();
return;
}
// Debounce: cancel previous pending search, wait briefly for typing to settle
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
var cts = new CancellationTokenSource();
_searchDebounceCts = cts;
try
{
await Task.Delay(300, cts.Token);
}
catch (TaskCanceledException)
{
return;
}
// Serialize DB access: scoped DbContext must not be used concurrently
await _searchLock.WaitAsync();
try
{
if (cts.IsCancellationRequested) return;
searchResults = await TireStackService.SearchAsync(searchTerm);
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Suchfehler: {ex.Message}";
}
finally
{
_searchLock.Release();
}
}
private void AddToScannedList(TireStack tireStack)
{
if (tireStack.Status != TireStackStatus.Stored)
{
errorMessage = $"'{tireStack.TicketNumber}' ist nicht eingelagert";
return;
}
if (scannedTireStacks.Any(ts => ts.Id == tireStack.Id))
{
// Remove if already in list (toggle behavior)
scannedTireStacks.RemoveAll(ts => ts.Id == tireStack.Id);
if (lastAddedTireStack?.Id == tireStack.Id)
{
lastAddedTireStack = null;
}
}
else
{
scannedTireStacks.Add(tireStack);
lastAddedTireStack = tireStack;
}
errorMessage = null;
}
private void RemoveScannedItem(TireStack tireStack)
{
scannedTireStacks.Remove(tireStack);
if (lastAddedTireStack?.Id == tireStack.Id)
{
lastAddedTireStack = null;
}
}
private void ClearAllScanned()
{
scannedTireStacks.Clear();
lastAddedTireStack = null;
errorMessage = null;
successMessage = null;
}
private async Task ProcessBatchPickup()
{
if (scannedTireStacks.Count == 0) return;
isProcessing = true;
errorMessage = null;
StateHasChanged();
try
{
pickedUpTireStacks.Clear();
freedLocations.Clear();
foreach (var tireStack in scannedTireStacks)
{
var locationCode = tireStack.StorageLocation?.Code;
if (!string.IsNullOrEmpty(locationCode) && !freedLocations.Contains(locationCode))
{
freedLocations.Add(locationCode);
}
}
var pickedUp = await TireStackService.PickupBatchAsync(
scannedTireStacks.Select(ts => ts.Id),
UserSession.CurrentUser?.Id);
pickedUpTireStacks.AddRange(pickedUp);
showSuccess = true;
scannedTireStacks.Clear();
searchResults.Clear();
searchTerm = "";
lastAddedTireStack = null;
}
catch (Exception ex)
{
errorMessage = $"Fehler bei Abholung: {ex.Message}";
pickedUpTireStacks.Clear();
freedLocations.Clear();
}
finally
{
isProcessing = false;
}
}
private void CloseSuccess()
{
showSuccess = false;
pickedUpTireStacks.Clear();
freedLocations.Clear();
}
public void Dispose()
{
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
_searchLock.Dispose();
}
}
@@ -0,0 +1,746 @@
/* ========================================
Tire Check-out Page Styles
======================================== */
.checkout-page {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-bg-gradient);
padding: var(--space-4);
padding-bottom: calc(var(--space-4) + var(--safe-area-bottom));
}
.page-header {
text-align: center;
padding: 1rem 0 1.5rem;
}
.page-header h1 {
font-family: var(--font-family-heading);
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 0.5rem 0;
}
.page-header p {
font-family: var(--font-family-base);
color: var(--text-placeholder);
margin: 0;
font-size: var(--text-sm);
}
/* ========================================
Checkout Card
======================================== */
.checkout-card {
background: rgba(28, 28, 30, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 1.5rem;
max-width: 500px;
margin: 0 auto 1.5rem;
}
/* ========================================
Last Scan Display
======================================== */
.last-scan-display {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2) 0%, rgba(96, 165, 250, 0.1) 100%);
border: 1px solid rgba(96, 165, 250, 0.4);
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 10px;
animation: scanPulse 0.5s ease-out;
}
@keyframes scanPulse {
0% {
transform: scale(0.95);
opacity: 0;
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.last-scan-display .scan-label {
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
}
.last-scan-display .scan-code {
font-family: 'Orbitron', monospace;
font-size: 1rem;
font-weight: 600;
color: #93c5fd;
flex: 1;
word-break: break-all;
}
/* ========================================
Scan Section
======================================== */
.scan-section {
margin-bottom: 1rem;
}
.camera-wrapper {
background: #000;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 1;
max-height: 250px;
margin-bottom: 1rem;
}
#qr-reader-checkout {
width: 100%;
height: 100%;
}
#qr-reader-checkout video {
width: 100%;
height: 100%;
object-fit: cover;
}
.scanner-controls {
display: flex;
justify-content: center;
}
/* ========================================
Search Section
======================================== */
.search-section {
margin-bottom: 1rem;
}
.search-input {
width: 100%;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.3);
border: 0.5px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #ffffff;
font-family: 'Rajdhani', sans-serif;
font-size: 1rem;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: rgba(220, 38, 38, 0.5);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
.search-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
/* ========================================
Scanned Items Section
======================================== */
.scanned-section {
background: rgba(28, 28, 30, 0.8);
border: 0.5px solid rgba(34, 197, 94, 0.3);
border-radius: 20px;
padding: 1.5rem;
max-width: 500px;
margin: 0 auto 1.5rem;
}
.scanned-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.scanned-header h3 {
font-family: 'Rajdhani', sans-serif;
font-size: 1.1rem;
font-weight: 600;
color: #ffffff;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.scan-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(34, 197, 94, 0.3);
border-radius: 50%;
font-family: 'Orbitron', monospace;
font-weight: 700;
color: #86efac;
}
/* btn-sm, btn-outline-danger: inherited from app.css (section 5) */
/* ========================================
Scanned Summary
======================================== */
.scanned-summary {
display: flex;
gap: 12px;
margin-bottom: 1rem;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
}
.summary-item {
flex: 1;
text-align: center;
}
.summary-value {
display: block;
font-family: 'Orbitron', monospace;
font-size: 1.25rem;
font-weight: 700;
color: #22c55e;
}
.summary-label {
font-family: 'Rajdhani', sans-serif;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
}
/* ========================================
Scanned List
======================================== */
.scanned-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 1rem;
}
.scanned-item {
background: rgba(0, 0, 0, 0.3);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 12px;
transition: all 0.2s ease;
}
.scanned-item.highlight {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
animation: highlightFade 3s ease-out;
}
@keyframes highlightFade {
0% { background: rgba(34, 197, 94, 0.3); }
100% { background: rgba(34, 197, 94, 0.15); }
}
.scanned-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.scanned-item-main {
display: flex;
align-items: center;
gap: 10px;
}
.scanned-item-header .ticket-number {
font-family: 'Orbitron', monospace;
font-weight: 600;
color: #ef4444;
font-size: 0.9rem;
}
.location-badge {
font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif;
font-size: 0.8rem;
color: #60a5fa;
background: rgba(96, 165, 250, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
.btn-remove {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove:hover {
background: rgba(239, 68, 68, 0.3);
color: #f87171;
}
.scanned-item-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.scanned-customer {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.scanned-customer .customer-name {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
color: #ffffff;
font-size: 0.95rem;
}
.scanned-customer .customer-name.inventory {
font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif;
color: #93c5fd;
}
.scanned-customer .vehicle-info {
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
}
.scanned-details {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
}
.scanned-details .tire-count {
font-weight: 600;
color: #ffffff;
}
.scanned-details .tire-season {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
font-size: 1rem;
line-height: 1;
}
.scanned-details .with-rims {
background: rgba(255, 255, 255, 0.1);
padding: 1px 6px;
border-radius: 4px;
font-size: 0.75rem;
}
.scanned-brand {
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.scanned-qr {
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
padding: 6px 8px;
background: rgba(34, 197, 94, 0.08);
border-radius: 6px;
}
.scanned-qr .qr-label {
color: rgba(255, 255, 255, 0.4);
margin-right: 4px;
}
.scanned-qr .qr-codes {
font-family: 'Orbitron', monospace;
font-size: 0.75rem;
color: #86efac;
word-break: break-all;
}
.scanned-meta {
display: flex;
gap: 6px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.4);
}
.scanned-actions {
margin-top: 1rem;
}
/* ========================================
Results Section
======================================== */
.results-section {
max-width: 500px;
margin: 0 auto 1.5rem;
}
.results-section h3 {
font-family: 'Rajdhani', sans-serif;
font-size: 1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 1rem 0;
}
.tireset-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.tireset-card {
background: rgba(28, 28, 30, 0.8);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.tireset-card:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.tireset-card.selected,
.tireset-card.already-scanned {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.05);
}
.tireset-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 0.5px solid rgba(255, 255, 255, 0.05);
}
.ticket-number {
font-family: 'Orbitron', monospace;
font-weight: 600;
color: #ef4444;
font-size: 0.95rem;
}
.status-badge {
padding: 4px 10px;
border-radius: 6px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.stored {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.status-badge.pickedup {
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
}
.status-badge.reserved {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.tireset-body {
padding: 12px 16px;
}
.customer-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.customer-name {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
color: #ffffff;
font-size: 1rem;
}
.vehicle-info {
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
}
.tire-info {
margin-bottom: 8px;
}
.tire-count {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
color: #ffffff;
font-size: 0.9rem;
margin-right: 8px;
}
.tire-details {
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
}
/* QR Info in search results */
.qr-info {
margin-bottom: 8px;
padding: 8px;
background: rgba(34, 197, 94, 0.08);
border-radius: 6px;
}
.qr-info .qr-label {
font-family: 'Rajdhani', sans-serif;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
display: block;
margin-bottom: 4px;
}
.qr-info .qr-codes {
font-family: 'Orbitron', monospace;
font-size: 0.8rem;
color: #86efac;
word-break: break-all;
}
.location-info {
display: flex;
justify-content: space-between;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.location {
font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif;
color: #60a5fa;
}
/* Buttons: inherited from app.css (section 5) */
.w-100 {
width: 100%;
}
/* ========================================
Divider
======================================== */
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 1.25rem 0;
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.4);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.1);
}
/* Alerts: inherited from app.css (section 7) */
.alert {
margin-top: var(--space-3);
}
/* Spinner: inherited from app.css (section 11) */
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border-default);
border-top-color: var(--text-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ========================================
Success Modal
======================================== */
.success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.success-modal {
background: rgba(28, 28, 30, 0.95);
border: 0.5px solid rgba(255, 255, 255, 0.15);
border-radius: 24px;
padding: 2rem;
text-align: center;
max-width: 400px;
width: 100%;
}
.success-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.15);
border: 2px solid rgba(34, 197, 94, 0.4);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
font-size: 2.5rem;
color: #22c55e;
line-height: 1;
}
.success-modal h2 {
font-family: 'Rajdhani', sans-serif;
font-size: 1.5rem;
font-weight: 600;
color: #ffffff;
margin: 0 0 0.5rem 0;
}
.success-modal p {
font-family: 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 0.5rem 0;
}
.pickup-detail {
font-size: 1.1rem;
font-weight: 600;
color: #22c55e !important;
}
.pickup-list {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 12px;
margin: 1rem 0;
max-height: 200px;
overflow-y: auto;
text-align: left;
}
.pickup-item {
padding: 8px 0;
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
}
.pickup-item:last-child {
border-bottom: none;
}
.pickup-item .ticket {
font-family: 'Orbitron', monospace;
font-size: 0.85rem;
color: #ef4444;
display: block;
}
.pickup-item .info {
font-family: 'Rajdhani',sans-serif;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
}
.success-detail {
color: rgba(255, 255, 255, 0.5) !important;
font-size: 0.9rem;
margin-bottom: 1.5rem !important;
}
/* ========================================
Responsive
======================================== */
@media (min-width: 768px) {
.checkout-page {
padding: 2rem;
}
.page-header h1 {
font-size: 2rem;
}
.checkout-card,
.results-section,
.scanned-section {
max-width: 600px;
}
}
@media (min-width: 1024px) {
.checkout-page {
padding: 3rem;
}
.checkout-card,
.results-section,
.scanned-section {
max-width: 700px;
}
}
/* Reduced motion: inherited from app.css (section 17) */
@@ -0,0 +1,564 @@
@page "/tire-storage"
@implements IAsyncDisposable
@inject ITireStackService TireStackService
@inject IStorageLocationService StorageLocationService
@inject ICatalogService CatalogService
@inject UserSessionService UserSession
@inject IJSRuntime JS
@using Lagerbestand.Components.Shared
<PageTitle>Lagerübersicht - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Lagerübersicht - Reifen Späth Lagerverwaltung"
Description="Übersicht aller eingelagerten Reifenstapel in der Reifen Späth Lagerverwaltung."
Robots="noindex, nofollow" />
@if (!UserSession.IsWorkerOrAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Mitarbeiter und Administratoren zugänglich.</p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
</section>
}
else
{
<div class="storage-page">
<header class="page-header">
<div class="header-content">
<h1>Lagerübersicht</h1>
<nav class="header-actions" aria-label="Schnellaktionen">
<a href="/tire-checkin" class="btn btn-primary">
<span aria-hidden="true">+</span> Einlagern
</a>
<a href="/tire-checkout" class="btn btn-outline">
Abholen
</a>
</nav>
</div>
</header>
@* Statistics *@
<section class="stats-grid" aria-label="Lagerstatistik">
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.TotalStacksStored</span>
<span class="stat-label">Stapel (@stats.TotalTiresStored Reifen)</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.CustomerStackCount</span>
<span class="stat-label"><span aria-hidden="true">👤</span> Kundenstapel</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.InventoryStackCount</span>
<span class="stat-label"><span aria-hidden="true">📦</span> Lagerbestand</span>
</article>
<article class="stat-card">
<span class="stat-value" aria-label="Anzahl">@stats.AvailableLocations</span>
<span class="stat-label">Freie Plätze</span>
</article>
</section>
@* Type Filter (Customer vs Inventory) *@
<nav class="type-filter" role="tablist" aria-label="Filter nach Typ">
<button class="type-btn @(typeFilter == null ? "active" : "")"
@onclick="() => SetTypeFilter(null)"
role="tab"
aria-selected="@(typeFilter == null)">
Alle
</button>
<button class="type-btn @(typeFilter == false ? "active" : "")"
@onclick="() => SetTypeFilter(false)"
role="tab"
aria-selected="@(typeFilter == false)">
<span aria-hidden="true">👤</span> Kundenstapel
</button>
<button class="type-btn @(typeFilter == true ? "active" : "")"
@onclick="() => SetTypeFilter(true)"
role="tab"
aria-selected="@(typeFilter == true)">
<span aria-hidden="true">📦</span> Lagerbestand
</button>
</nav>
@* View Toggle (List / Rack Grid) *@
<nav class="view-toggle" role="tablist" aria-label="Ansicht wählen">
<button class="view-btn @(showGridView ? "" : "active")"
@onclick="() => showGridView = false"
role="tab"
aria-selected="@(!showGridView)">
<span aria-hidden="true">☰</span> Liste
</button>
<button class="view-btn @(showGridView ? "active" : "")"
@onclick="() => showGridView = true"
role="tab"
aria-selected="@showGridView">
<span aria-hidden="true">▦</span> Regalansicht
</button>
</nav>
@* Search & Filter *@
<section class="filter-bar" aria-label="Suche und Filter">
<label for="search-input" class="visually-hidden">Suche</label>
<input type="search"
id="search-input"
class="search-input"
placeholder="Suchen (Name, Fahrzeug, Kategorie, Ticket-Nr.)..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="FilterTireStacks"
aria-describedby="search-hint" />
<span id="search-hint" class="visually-hidden">Durchsucht Namen, Fahrzeuge, Kategorien und Ticket-Nummern</span>
<fieldset class="filter-buttons">
<legend class="visually-hidden">Nach Saison filtern</legend>
<button class="filter-btn @(seasonFilter == null ? "active" : "")"
@onclick="() => SetSeasonFilter(null)"
aria-pressed="@(seasonFilter == null)">
Alle
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Sommer ? "active summer" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Sommer)"
aria-pressed="@(seasonFilter == TireSeason.Sommer)"
aria-label="Sommerreifen">
<span aria-hidden="true">☀️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Winter ? "active winter" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Winter)"
aria-pressed="@(seasonFilter == TireSeason.Winter)"
aria-label="Winterreifen">
<span aria-hidden="true">❄️</span>
</button>
<button class="filter-btn @(seasonFilter == TireSeason.Allrad ? "active allseason" : "")"
@onclick="() => SetSeasonFilter(TireSeason.Allrad)"
aria-pressed="@(seasonFilter == TireSeason.Allrad)"
aria-label="Ganzjahresreifen">
<span aria-hidden="true">🔄</span>
</button>
</fieldset>
</section>
@* Category Filter (only when showing inventory) *@
@if (typeFilter == true && categories.Count > 0)
{
<section class="category-filter">
<label for="category-select" class="visually-hidden">Kategorie auswählen</label>
<select id="category-select" class="form-control" @bind="selectedCategoryId" @bind:after="FilterTireStacks">
<option value="">-- Alle Kategorien --</option>
@foreach (var cat in categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</select>
</section>
}
@* Tire Stacks List *@
@if (isLoading)
{
<div class="loading-state" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<span>Lade Daten...</span>
</div>
}
else if (showGridView)
{
<section class="rack-view" aria-label="Regalansicht der Lagerplätze">
@foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
{
<div class="building-section" @key="area">
<h2 class="building-title">@area.GetBuildingLabel()</h2>
@for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
{
var currentRow = row;
<div class="rack-row" @key="@($"{area}-{currentRow}")">
<h3 class="row-title">Reihe @currentRow</h3>
<div class="rack-sides">
@foreach (var side in new[] { "Links", "Rechts" })
{
<div class="rack-side" @key="side">
<span class="side-label">@side</span>
@foreach (var pos in ShelfPositionExtensions.All.Where(p => p.IsLeft() == (side == "Links")).OrderByDescending(p => p.IsTop()))
{
<div class="rack-level" @key="pos">
<span class="level-label">@(pos.IsTop() ? "Oben" : "Unten")</span>
<div class="rack-cells">
@for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
var loc = GetLocation(area, currentRow, pos, slot);
var cellClass = loc == null ? "missing" : !loc.IsAvailable ? "blocked" : loc.IsOccupied ? "occupied" : "free";
<button type="button"
class="rack-cell @cellClass @(selectedLocation?.Id == loc?.Id ? "selected" : "")"
@key="slot"
disabled="@(loc == null)"
title="@GetCellTooltip(loc, area, currentRow, pos, slot)"
@onclick="() => SelectLocation(loc)">
@slot
</button>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
</div>
}
<div class="rack-legend" aria-hidden="true">
<span class="legend-item"><span class="legend-swatch free"></span> Frei</span>
<span class="legend-item"><span class="legend-swatch occupied"></span> Belegt</span>
<span class="legend-item"><span class="legend-swatch blocked"></span> Gesperrt</span>
</div>
@if (selectedLocation != null)
{
<aside class="cell-details" aria-label="Details zum gewählten Lagerplatz">
<header class="cell-details-header">
<LocationDisplay Code="@selectedLocation.Code" DisplayMode="LocationDisplayMode.Full" IsOccupied="@selectedLocation.IsOccupied" ShowStatus="true" />
<button type="button" class="btn btn-sm btn-outline" @onclick="() => selectedLocation = null">Schließen</button>
</header>
@if (selectedLocation.IsOccupied && selectedLocation.OccupiedTireStack != null)
{
var stack = selectedLocation.OccupiedTireStack;
<dl class="detail-grid">
<div class="detail-item">
<dt class="label">Ticket-Nr.</dt>
<dd class="value">@stack.TicketNumber</dd>
</div>
<div class="detail-item">
<dt class="label">@(stack.IsInventory ? "Kategorie" : "Kunde")</dt>
<dd class="value">@(stack.IsInventory ? stack.CatalogItem?.Name ?? "Lagerbestand" : stack.Customer?.FullName ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Reifen</dt>
<dd class="value">@(stack.TireSize ?? "-") @(stack.WithRims ? "| mit Felgen" : "")</dd>
</div>
<div class="detail-item">
<dt class="label">Eingelagert</dt>
<dd class="value">@stack.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</dd>
</div>
</dl>
}
else if (!selectedLocation.IsAvailable)
{
<p class="cell-details-text">Dieser Lagerplatz ist gesperrt. @selectedLocation.Notes</p>
}
else
{
<p class="cell-details-text">Dieser Lagerplatz ist frei.</p>
<a href="/tire-checkin" class="btn btn-sm btn-primary">Einlagern</a>
}
</aside>
}
</section>
}
else if (filteredTireStacks.Count == 0)
{
<section class="empty-state" aria-labelledby="empty-title">
<div class="empty-icon" aria-hidden="true">📦</div>
<h2 id="empty-title">Keine Einträge</h2>
<p>@(string.IsNullOrEmpty(searchTerm) ? "Noch keine Reifenstapel eingelagert." : "Keine Ergebnisse für Ihre Suche.")</p>
@if (string.IsNullOrEmpty(searchTerm))
{
<a href="/tire-checkin" class="btn btn-primary">Reifenstapel einlagern</a>
}
</section>
}
else
{
<section class="tireset-list" aria-label="Liste der Reifenstapel" role="list">
@foreach (var ts in filteredTireStacks)
{
<article class="tireset-card @(ts.IsInventory ? "inventory" : "")"
@onclick="() => ToggleDetails(ts.Id)"
role="listitem"
aria-expanded="@(expandedId == ts.Id)">
<div class="tireset-main">
<div class="tireset-left">
<div class="season-indicator @ts.Season.ToString().ToLower()"
aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</div>
<div class="tireset-info">
@if (ts.IsInventory)
{
<span class="customer-name inventory-name">
<span aria-hidden="true">📦</span> @(ts.CatalogItem?.Name ?? "Lagerbestand")
</span>
}
else
{
<span class="customer-name">@ts.Customer?.FullName</span>
}
<span class="tire-details">1 Stapel (@TireStack.TiresPerStack Reifen) @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.WithRims ? "| mit Felgen" : "")</span>
</div>
</div>
<div class="tireset-right">
<span class="location-code">@(ts.StorageLocation?.Code ?? "-")</span>
@if (!ts.IsInventory && ts.Vehicle != null)
{
<span class="vehicle-info">@ts.Vehicle.ShortName</span>
}
@if (ts.IsInventory)
{
<span class="inventory-badge">Lager</span>
}
</div>
</div>
@if (expandedId == ts.Id)
{
<div class="tireset-details">
<dl class="detail-grid">
<div class="detail-item">
<dt class="label">Ticket-Nr.</dt>
<dd class="value">@ts.TicketNumber</dd>
</div>
<div class="detail-item">
<dt class="label">Reifenstapel</dt>
<dd class="value">1 Stapel (@TireStack.TiresPerStack Reifen)</dd>
</div>
@if (ts.IsInventory && ts.CatalogItem != null)
{
<div class="detail-item">
<dt class="label">Kategorie</dt>
<dd class="value">@ts.CatalogItem.Name</dd>
</div>
}
<div class="detail-item">
<dt class="label">Marke</dt>
<dd class="value">@(ts.TireBrand ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Modell</dt>
<dd class="value">@(ts.TireModel ?? "-")</dd>
</div>
<div class="detail-item">
<dt class="label">Zustand</dt>
<dd class="value">@ts.ConditionText</dd>
</div>
<div class="detail-item">
<dt class="label">Eingelagert</dt>
<dd class="value"><time datetime="@ts.StoredAt.ToString("yyyy-MM-dd")">@ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
</div>
<div class="detail-item">
<dt class="label">Dauer</dt>
<dd class="value">@ts.DaysStored Tage</dd>
</div>
</dl>
@if (!string.IsNullOrEmpty(ts.Notes))
{
<div class="notes-section">
<span class="label">Notizen:</span>
<span class="notes-text">@ts.Notes</span>
</div>
}
<nav class="detail-actions" aria-label="Aktionen für diesen Stapel">
@if (!ts.IsInventory)
{
<a href="/tire-checkout" class="btn btn-sm btn-primary">Abholen</a>
}
else
{
<a href="/tire-checkout" class="btn btn-sm btn-primary">Entnehmen</a>
}
</nav>
</div>
}
</article>
}
</section>
}
@* Expiring Pickups Warning (only for customer stacks) *@
@if (expiringPickups.Count > 0)
{
<aside class="expiring-section" aria-labelledby="expiring-title" role="alert">
<h2 id="expiring-title"><span aria-hidden="true">⚠️</span> Bevorstehende Abholungen</h2>
<ul class="expiring-list">
@foreach (var ts in expiringPickups)
{
<li class="expiring-item">
<span class="customer">@ts.Customer?.FullName</span>
<span class="date">Erwartet: <time datetime="@ts.ExpectedPickupDate?.ToString("yyyy-MM-dd")">@ts.ExpectedPickupDate?.ToLocalTime().ToString("dd.MM.yyyy")</time></span>
</li>
}
</ul>
</aside>
}
</div>
}
@code {
private List<TireStack> allTireStacks = new();
private List<TireStack> filteredTireStacks = new();
private List<TireStack> expiringPickups = new();
private List<CatalogItem> categories = new();
private StorageStatistics stats = new();
private string searchTerm = "";
private TireSeason? seasonFilter;
private bool? typeFilter; // null = all, false = customer, true = inventory
private string selectedCategoryId = "";
private Guid? expandedId;
private bool isLoading = true;
private bool showGridView;
private List<StorageLocation> allLocations = new();
private Dictionary<(AreaType, int, ShelfPosition, int), StorageLocation> locationLookup = new();
private StorageLocation? selectedLocation;
protected override async Task OnInitializedAsync()
{
if (UserSession.IsWorkerOrAdmin)
{
await LoadData();
}
}
private DotNetObjectReference<TireStorage>? _visibilityRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsWorkerOrAdmin)
{
_visibilityRef = DotNetObjectReference.Create(this);
await VisibilityRefresh.RegisterAsync(JS, _visibilityRef, nameof(OnVisibilityReturn), 120);
}
}
[JSInvokable]
public async Task OnVisibilityReturn(int hiddenSeconds)
{
if (!UserSession.IsWorkerOrAdmin) return;
await LoadData();
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
await VisibilityRefresh.UnregisterAsync(JS);
_visibilityRef?.Dispose();
}
private async Task LoadData()
{
isLoading = true;
StateHasChanged();
try
{
allTireStacks = await TireStackService.GetAllAsync(TireStackStatus.Stored);
stats = await TireStackService.GetStatisticsAsync();
expiringPickups = await TireStackService.GetExpiringPickupsAsync();
categories = await CatalogService.GetAllAsync();
allLocations = await StorageLocationService.GetAllAsync();
locationLookup = allLocations.ToDictionary(l => (l.Area, l.Row, l.Position, l.SlotNumber));
if (selectedLocation != null)
{
selectedLocation = allLocations.FirstOrDefault(l => l.Id == selectedLocation.Id);
}
ApplyFilters();
}
finally
{
isLoading = false;
}
}
private void FilterTireStacks()
{
ApplyFilters();
}
private void SetSeasonFilter(TireSeason? season)
{
seasonFilter = season;
ApplyFilters();
}
private void SetTypeFilter(bool? isInventory)
{
typeFilter = isInventory;
selectedCategoryId = ""; // Reset category filter when changing type
ApplyFilters();
}
private void ApplyFilters()
{
var query = allTireStacks.AsEnumerable();
// Type filter (customer vs inventory)
if (typeFilter.HasValue)
{
if (typeFilter.Value)
query = query.Where(ts => ts.CustomerId == null);
else
query = query.Where(ts => ts.CustomerId != null);
}
// Season filter
if (seasonFilter.HasValue)
{
query = query.Where(ts => ts.Season == seasonFilter.Value);
}
// Category filter (for inventory)
if (!string.IsNullOrEmpty(selectedCategoryId) && Guid.TryParse(selectedCategoryId, out var catId))
{
query = query.Where(ts => ts.CatalogItemId == catId);
}
// Search term
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower();
query = query.Where(ts =>
(ts.Customer?.FullName?.ToLower().Contains(term) ?? false) ||
(ts.Vehicle?.Make?.ToLower().Contains(term) ?? false) ||
(ts.Vehicle?.Model?.ToLower().Contains(term) ?? false) ||
(ts.Customer?.CustomerNumber?.ToLower().Contains(term) ?? false) ||
(ts.CatalogItem?.Name?.ToLower().Contains(term) ?? false) ||
(ts.StorageLocation?.Code?.ToLower().Contains(term) ?? false) ||
ts.TicketNumber.ToLower().Contains(term) ||
(ts.TireBrand?.ToLower().Contains(term) ?? false) ||
(ts.TireSize?.ToLower().Contains(term) ?? false)
);
}
filteredTireStacks = query.OrderBy(ts => ts.StorageLocation?.Code).ToList();
}
private void ToggleDetails(Guid id)
{
expandedId = expandedId == id ? null : id;
}
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
{
return locationLookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
}
private void SelectLocation(StorageLocation? location)
{
if (location == null) return;
selectedLocation = selectedLocation?.Id == location.Id ? null : location;
}
private string GetCellTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot)
{
var baseText = $"{area.GetDisplayName()}, Reihe {row}, {pos.GetDisplayName()}, Platz {slot}";
if (loc == null) return $"{baseText} – nicht angelegt";
if (!loc.IsAvailable) return $"{baseText} – gesperrt";
if (loc.IsOccupied)
{
var stack = loc.OccupiedTireStack;
var who = stack == null ? "belegt" : stack.IsInventory ? stack.CatalogItem?.Name ?? "Lagerbestand" : stack.Customer?.FullName ?? "belegt";
return $"{baseText} – {who}";
}
return $"{baseText} – frei";
}
}
File diff suppressed because it is too large. Load diff
+983
View File
@@ -0,0 +1,983 @@
@page "/users"
@implements IAsyncDisposable
@inject IAuthService AuthService
@inject ITwoFactorService TwoFactorService
@inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using Lagerbestand.Components.Shared
<PageTitle>Benutzerverwaltung - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Benutzerverwaltung - Reifen Späth Lagerverwaltung"
Description="Verwaltung der Benutzerkonten in der Reifen Späth Lagerverwaltung."
Robots="noindex, nofollow" />
@if (!UserSession.IsAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">??</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Sie haben keine Berechtigung für diese Seite.</p>
<a href="/tire-storage" class="btn btn-primary">Zur Lagerübersicht</a>
</section>
}
else
{
<main class="users-container" role="main">
<header class="header-row">
<div class="header-info">
<h1>Benutzerverwaltung</h1>
<p class="header-subtitle">@users.Count Benutzer insgesamt · @users.Count(u => u.IsActive) aktiv</p>
</div>
<div class="header-actions">
<button class="btn btn-outline-primary btn-sm" @onclick="LoadUsers" disabled="@isLoading" title="Aktualisieren">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
<button class="btn btn-primary" @onclick="OpenCreateUserModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Neuer Benutzer
</button>
</div>
</header>
@if (isLoading)
{
<div class="loading-spinner">
<div class="spinner-border" role="status"></div>
<p>Lade Benutzer...</p>
</div>
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">
<span>@errorMessage</span>
<button type="button" class="alert-dismiss" @onclick="() => errorMessage = null">×</button>
</div>
}
else
{
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success">
<span>@successMessage</span>
<button type="button" class="alert-dismiss" @onclick="() => successMessage = null">×</button>
</div>
}
<div class="users-table">
<div class="table-header">
<span class="col-username">Benutzername</span>
<span class="col-displayname">Anzeigename</span>
<span class="col-role">Rolle</span>
<span class="col-2fa">2FA</span>
<span class="col-status">Status</span>
<span class="col-lastlogin">Letzter Login</span>
<span class="col-actions">Aktionen</span>
</div>
<div class="table-body">
@foreach (var user in users)
{
var isCurrentUser = user.Id == UserSession.CurrentUser?.Id;
<div class="table-row @(user.IsActive ? "" : "inactive") @(isCurrentUser ? "current-user" : "")">
<span class="col-username">
@user.Username
@if (isCurrentUser)
{
<span class="current-user-badge">Sie</span>
}
</span>
<span class="col-displayname">@(user.DisplayName ?? "-")</span>
<span class="col-role">
<span class="role-badge @GetRoleClass(user.Role)">@GetRoleName(user.Role)</span>
</span>
<span class="col-2fa">
@if (twoFactorGlobalEnabled)
{
@if (user.TwoFactorEnabled)
{
<span class="tfa-badge tfa-on" title="@(user.TwoFactorEnforced ? "2FA aktiv (erzwungen)" : "2FA aktiv")">
?? @(user.TwoFactorEnforced ? "Erzwungen" : "Aktiv")
</span>
}
else
{
<span class="tfa-badge tfa-off" title="2FA nicht eingerichtet">Aus</span>
}
}
else
{
<span class="text-muted">—</span>
}
</span>
<span class="col-status">
<span class="status-badge @(user.IsActive ? "active" : "inactive")">
@(user.IsActive ? "Aktiv" : "Inaktiv")
</span>
</span>
<span class="col-lastlogin">
@if (user.LastLoginAt.HasValue)
{
<span title="@user.LastLoginAt.Value.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss")">
@FormatLastLogin(user.LastLoginAt.Value)
</span>
}
else
{
<span class="text-muted">Nie</span>
}
</span>
<span class="col-actions">
<button class="btn-icon-sm" @onclick="() => OpenEditUserModal(user)" title="Bearbeiten">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="btn-icon-sm" @onclick="() => OpenPasswordModal(user)" title="Passwort ändern">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
@if (twoFactorGlobalEnabled)
{
@if (user.TwoFactorEnabled)
{
<button class="btn-icon-sm warning" @onclick="() => Reset2Fa(user)" title="2FA zurücksetzen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
}
@if (!user.TwoFactorEnforced)
{
<button class="btn-icon-sm" @onclick="() => Enforce2Fa(user, true)" title="2FA erzwingen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</button>
}
else
{
<button class="btn-icon-sm" @onclick="() => Enforce2Fa(user, false)" title="2FA-Zwang aufheben">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke-dasharray="4 4"></path>
</svg>
</button>
}
}
@if (!isCurrentUser)
{
<button class="btn-icon-sm danger" @onclick="() => OpenDeleteModal(user)" title="Löschen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
}
</span>
</div>
}
</div>
</div>
@if (users.Count == 0)
{
<div class="empty-state">
<div class="empty-icon">??</div>
<h3>Keine Benutzer gefunden</h3>
<p>Erstellen Sie einen neuen Benutzer, um zu beginnen.</p>
</div>
}
}
</main>
@* Create/Edit User Modal *@
@if (showUserModal)
{
<div class="modal-backdrop" @onclick="CloseUserModal"></div>
<div class="app-modal" @onclick="CloseUserModal" role="dialog" aria-modal="true">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(isEditingUser ? "Benutzer bearbeiten" : "Neuer Benutzer")</h5>
<button type="button" class="btn-close" @onclick="CloseUserModal" disabled="@isSaving"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Benutzername <span class="required">*</span></label>
<input type="text"
class="form-control @(usernameError != null ? "is-invalid" : "")"
@bind="editUsername"
@bind:event="oninput"
@onfocusout="ValidateUsernameField"
placeholder="Benutzername eingeben"
disabled="@isSaving"
maxlength="50" />
@if (usernameError != null)
{
<div class="invalid-feedback">@usernameError</div>
}
<small class="form-text">3-50 Zeichen, nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche</small>
</div>
@if (!isEditingUser)
{
<div class="mb-3">
<label class="form-label">Passwort <span class="required">*</span></label>
<input type="password"
class="form-control @(passwordError != null ? "is-invalid" : "")"
@bind="newPassword"
@bind:event="oninput"
@onfocusout="ValidatePasswordField"
placeholder="Passwort eingeben"
disabled="@isSaving" />
@if (passwordError != null)
{
<div class="invalid-feedback">@passwordError</div>
}
<small class="form-text">Mindestens 4 Zeichen</small>
</div>
}
<div class="mb-3">
<label class="form-label">Anzeigename</label>
<input type="text"
class="form-control"
@bind="editDisplayName"
placeholder="Optionaler Anzeigename"
disabled="@isSaving"
maxlength="200" />
<small class="form-text">Wird in der Oberfläche angezeigt, falls gesetzt</small>
</div>
<div class="mb-3">
<label class="form-label">Rolle <span class="required">*</span></label>
<select class="form-select" @bind="editRole" disabled="@isSaving">
<option value="@UserRole.User">Benutzer - Nur Anzeige</option>
<option value="@UserRole.Worker">Mitarbeiter - Lager, Einlagern, Abholen</option>
<option value="@UserRole.Admin">Administrator - Volle Kontrolle</option>
</select>
@if (isEditingUser && editUserId == UserSession.CurrentUser?.Id && editRole != UserRole.Admin)
{
<div class="alert alert-warning mt-2 mb-0">
<small>Achtung: Sie ändern Ihre eigene Rolle. Sie verlieren möglicherweise Admin-Rechte.</small>
</div>
}
</div>
@if (isEditingUser)
{
<div class="mb-3">
<label class="form-check">
<input type="checkbox" class="form-check-input" @bind="editIsActive" disabled="@isSaving" />
<span class="form-check-label">Benutzer ist aktiv</span>
</label>
@if (!editIsActive)
{
<small class="form-text text-warning">Inaktive Benutzer können sich nicht anmelden.</small>
}
</div>
}
@if (!string.IsNullOrEmpty(modalError))
{
<div class="alert alert-danger">@modalError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseUserModal" disabled="@isSaving">
Abbrechen
</button>
<button type="button" class="btn btn-primary" @onclick="SaveUser" disabled="@isSaving">
@if (isSaving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(isEditingUser ? "Speichern" : "Erstellen")
</button>
</div>
</div>
</div>
</div>
}
@* Password Modal *@
@if (showPasswordModal)
{
<div class="modal-backdrop" @onclick="ClosePasswordModal"></div>
<div class="app-modal" @onclick="ClosePasswordModal" role="dialog" aria-modal="true">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Passwort ändern</h5>
<button type="button" class="btn-close" @onclick="ClosePasswordModal" disabled="@isSaving"></button>
</div>
<div class="modal-body">
<div class="user-info-box mb-3">
<span class="label">Benutzer:</span>
<span class="value">@passwordUser?.Username</span>
</div>
<div class="mb-3">
<label class="form-label">Neues Passwort <span class="required">*</span></label>
<input type="password"
class="form-control"
@bind="newPassword"
@bind:event="oninput"
placeholder="Neues Passwort eingeben"
disabled="@isSaving" />
</div>
<div class="mb-3">
<label class="form-label">Passwort bestätigen <span class="required">*</span></label>
<input type="password"
class="form-control @(newPassword != confirmPassword && !string.IsNullOrEmpty(confirmPassword) ? "is-invalid" : "")"
@bind="confirmPassword"
@bind:event="oninput"
placeholder="Passwort wiederholen"
disabled="@isSaving" />
@if (newPassword != confirmPassword && !string.IsNullOrEmpty(confirmPassword))
{
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
}
</div>
@if (!string.IsNullOrEmpty(modalError))
{
<div class="alert alert-danger">@modalError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="ClosePasswordModal" disabled="@isSaving">
Abbrechen
</button>
<button type="button" class="btn btn-primary" @onclick="ChangePassword" disabled="@(isSaving || newPassword != confirmPassword)">
@if (isSaving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Passwort ändern
</button>
</div>
</div>
</div>
</div>
}
@* Delete Confirmation Modal *@
@if (showDeleteModal)
{
<div class="modal-backdrop" @onclick="CloseDeleteModal"></div>
<div class="app-modal" @onclick="CloseDeleteModal" role="dialog" aria-modal="true">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Benutzer löschen</h5>
<button type="button" class="btn-close" @onclick="CloseDeleteModal" disabled="@isSaving"></button>
</div>
<div class="modal-body">
<div class="delete-warning">
<div class="warning-icon">??</div>
<p class="warning-text">
Möchten Sie den Benutzer <strong>@deleteUser?.Username</strong> wirklich löschen?
</p>
<p class="warning-subtext">
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</div>
@if (!string.IsNullOrEmpty(modalError))
{
<div class="alert alert-danger">@modalError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseDeleteModal" disabled="@isSaving">
Abbrechen
</button>
<button type="button" class="btn btn-danger" @onclick="DeleteUser" disabled="@isSaving">
@if (isSaving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Endgültig löschen
</button>
</div>
</div>
</div>
</div>
}
@* 2FA Setup Modal *@
@if (show2FaSetupModal)
{
<div class="modal-backdrop" @onclick="Close2FaSetupModal"></div>
<div class="app-modal" @onclick="Close2FaSetupModal" role="dialog" aria-modal="true">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">2FA einrichten – @setup2FaUser?.Username</h5>
<button type="button" class="btn-close" @onclick="Close2FaSetupModal" disabled="@isSaving"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(setup2FaQrDataUrl))
{
<div class="tfa-setup-instructions">
<p>Scannen Sie den QR-Code mit einer Authenticator-App (z.B. Google Authenticator, Microsoft Authenticator, Authy):</p>
</div>
<div class="tfa-qr-container">
<img src="@setup2FaQrDataUrl" alt="2FA QR-Code" class="tfa-qr-image" />
</div>
<div class="tfa-manual-key">
<span class="label">Manueller Schlüssel:</span>
<code class="secret-key">@setup2FaSecret</code>
</div>
<div class="tfa-confirm-section">
<label class="form-label">Bestätigungscode eingeben <span class="required">*</span></label>
<input type="text"
class="form-control tfa-confirm-input"
@bind="setup2FaCode"
@bind:event="oninput"
placeholder="6-stelliger Code"
maxlength="6"
inputmode="numeric"
disabled="@isSaving" />
<small class="form-text">Geben Sie den Code aus Ihrer Authenticator-App ein, um die Einrichtung abzuschließen.</small>
</div>
}
@if (!string.IsNullOrEmpty(modalError))
{
<div class="alert alert-danger">@modalError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close2FaSetupModal" disabled="@isSaving">
Abbrechen
</button>
<button type="button" class="btn btn-primary" @onclick="Confirm2FaSetup" disabled="@(isSaving || string.IsNullOrWhiteSpace(setup2FaCode) || setup2FaCode?.Length != 6)">
@if (isSaving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Bestätigen & Aktivieren
</button>
</div>
</div>
</div>
</div>
}
}
@code {
private List<AppUser> users = new();
private bool isLoading = true;
private string? errorMessage;
private string? successMessage;
private bool twoFactorGlobalEnabled;
// User Modal
private bool showUserModal;
private bool isEditingUser;
private bool isSaving;
private string? modalError;
private Guid editUserId;
private string editUsername = "";
private string editDisplayName = "";
private UserRole editRole = UserRole.User;
private bool editIsActive = true;
private string newPassword = "";
private string? usernameError;
private string? passwordError;
// Password Modal
private bool showPasswordModal;
private AppUser? passwordUser;
private string confirmPassword = "";
// Delete Modal
private bool showDeleteModal;
private AppUser? deleteUser;
// 2FA Setup Modal
private bool show2FaSetupModal;
private AppUser? setup2FaUser;
private string? setup2FaSecret;
private string? setup2FaQrDataUrl;
private string? setup2FaCode;
private bool setup2FaEnforceAfterConfirm;
protected override async Task OnInitializedAsync()
{
if (UserSession.IsAdmin)
{
await LoadUsers();
var tfSettings = await SiteSettingsService.GetTwoFactorSettingsAsync();
twoFactorGlobalEnabled = tfSettings.IsEnabled;
}
}
private DotNetObjectReference<Users>? _visibilityRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsAdmin)
{
_visibilityRef = DotNetObjectReference.Create(this);
await VisibilityRefresh.RegisterAsync(JS, _visibilityRef, nameof(OnVisibilityReturn), 120);
}
}
[JSInvokable]
public async Task OnVisibilityReturn(int hiddenSeconds)
{
if (!UserSession.IsAdmin) return;
await LoadUsers();
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
await VisibilityRefresh.UnregisterAsync(JS);
_visibilityRef?.Dispose();
}
private async Task LoadUsers()
{
isLoading = true;
errorMessage = null;
StateHasChanged();
try
{
users = await AuthService.GetAllUsersAsync();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Laden der Benutzer: {ex.Message}";
}
finally
{
isLoading = false;
}
}
#region User Modal
private void OpenCreateUserModal()
{
editUserId = Guid.Empty;
editUsername = "";
editDisplayName = "";
editRole = UserRole.User;
editIsActive = true;
newPassword = "";
usernameError = null;
passwordError = null;
isEditingUser = false;
modalError = null;
showUserModal = true;
}
private void OpenEditUserModal(AppUser user)
{
editUserId = user.Id;
editUsername = user.Username;
editDisplayName = user.DisplayName ?? "";
editRole = user.Role;
editIsActive = user.IsActive;
newPassword = "";
usernameError = null;
passwordError = null;
isEditingUser = true;
modalError = null;
showUserModal = true;
}
private void CloseUserModal()
{
if (!isSaving)
{
showUserModal = false;
ResetUserForm();
}
}
private void ResetUserForm()
{
editUserId = Guid.Empty;
editUsername = "";
editDisplayName = "";
editRole = UserRole.User;
editIsActive = true;
newPassword = "";
usernameError = null;
passwordError = null;
modalError = null;
}
private void ValidateUsernameField()
{
var validation = AuthService.ValidateUsername(editUsername);
usernameError = validation.IsValid ? null : validation.ErrorMessage;
}
private void ValidatePasswordField()
{
if (!isEditingUser)
{
var validation = AuthService.ValidatePassword(newPassword);
passwordError = validation.IsValid ? null : validation.ErrorMessage;
}
}
private async Task SaveUser()
{
modalError = null;
usernameError = null;
passwordError = null;
// Validate username
var usernameValidation = AuthService.ValidateUsername(editUsername);
if (!usernameValidation.IsValid)
{
usernameError = usernameValidation.ErrorMessage;
return;
}
// Validate password for new users
if (!isEditingUser)
{
var passwordValidation = AuthService.ValidatePassword(newPassword);
if (!passwordValidation.IsValid)
{
passwordError = passwordValidation.ErrorMessage;
return;
}
}
isSaving = true;
StateHasChanged();
try
{
if (isEditingUser)
{
var updated = await AuthService.UpdateUserAsync(editUserId, editUsername, editDisplayName, editRole, editIsActive);
var index = users.FindIndex(u => u.Id == editUserId);
if (index >= 0)
{
users[index] = updated;
}
// Update current session if editing self
if (editUserId == UserSession.CurrentUser?.Id)
{
UserSession.UpdateUser(updated);
}
successMessage = $"Benutzer '{updated.Username}' wurde aktualisiert.";
}
else
{
var created = await AuthService.CreateUserAsync(editUsername, newPassword, editDisplayName, editRole);
users.Add(created);
users = users.OrderBy(u => u.Username).ToList();
successMessage = $"Benutzer '{created.Username}' wurde erstellt.";
}
showUserModal = false;
ResetUserForm();
}
catch (Exception ex)
{
modalError = ex.Message;
}
finally
{
isSaving = false;
}
}
#endregion
#region Password Modal
private void OpenPasswordModal(AppUser user)
{
passwordUser = user;
newPassword = "";
confirmPassword = "";
modalError = null;
showPasswordModal = true;
}
private void ClosePasswordModal()
{
if (!isSaving)
{
showPasswordModal = false;
passwordUser = null;
newPassword = "";
confirmPassword = "";
modalError = null;
}
}
private async Task ChangePassword()
{
if (passwordUser == null) return;
var validation = AuthService.ValidatePassword(newPassword);
if (!validation.IsValid)
{
modalError = validation.ErrorMessage;
return;
}
if (newPassword != confirmPassword)
{
modalError = "Passwörter stimmen nicht überein.";
return;
}
isSaving = true;
modalError = null;
StateHasChanged();
try
{
await AuthService.ChangePasswordAsync(passwordUser.Id, newPassword);
successMessage = $"Passwort für '{passwordUser.Username}' wurde geändert.";
showPasswordModal = false;
passwordUser = null;
newPassword = "";
confirmPassword = "";
}
catch (Exception ex)
{
modalError = ex.Message;
}
finally
{
isSaving = false;
}
}
#endregion
#region Delete Modal
private void OpenDeleteModal(AppUser user)
{
deleteUser = user;
modalError = null;
showDeleteModal = true;
}
private void CloseDeleteModal()
{
if (!isSaving)
{
showDeleteModal = false;
deleteUser = null;
modalError = null;
}
}
private async Task DeleteUser()
{
if (deleteUser == null) return;
isSaving = true;
modalError = null;
StateHasChanged();
try
{
var username = deleteUser.Username;
await AuthService.DeleteUserAsync(deleteUser.Id);
users.RemoveAll(u => u.Id == deleteUser.Id);
successMessage = $"Benutzer '{username}' wurde gelöscht.";
showDeleteModal = false;
deleteUser = null;
}
catch (Exception ex)
{
modalError = ex.Message;
}
finally
{
isSaving = false;
}
}
#endregion
#region 2FA Actions
private async Task Reset2Fa(AppUser user)
{
try
{
await AuthService.Reset2FaAsync(user.Id);
user.TwoFactorEnabled = false;
user.TwoFactorSecret = null;
user.TwoFactorEnforced = false;
successMessage = $"2FA für '{user.Username}' wurde zurückgesetzt.";
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private async Task Enforce2Fa(AppUser user, bool enforce)
{
try
{
await AuthService.SetEnforce2FaAsync(user.Id, enforce);
user.TwoFactorEnforced = enforce;
if (enforce && !user.TwoFactorEnabled)
{
// User doesn't have 2FA set up yet — open setup modal with QR code
await Open2FaSetupModal(user, enforceAfterConfirm: true);
}
else
{
successMessage = enforce
? $"2FA wird für '{user.Username}' erzwungen."
: $"2FA-Zwang für '{user.Username}' aufgehoben.";
}
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private async Task Open2FaSetupModal(AppUser user, bool enforceAfterConfirm = false)
{
try
{
var result = await AuthService.Enable2FaAsync(user.Id);
setup2FaUser = user;
setup2FaSecret = result.Secret;
setup2FaQrDataUrl = TwoFactorService.GenerateQrCodeDataUrl(result.OtpAuthUri);
setup2FaCode = "";
setup2FaEnforceAfterConfirm = enforceAfterConfirm;
modalError = null;
show2FaSetupModal = true;
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Einrichten von 2FA: {ex.Message}";
}
}
private async Task Confirm2FaSetup()
{
if (setup2FaUser == null || string.IsNullOrWhiteSpace(setup2FaCode)) return;
isSaving = true;
modalError = null;
StateHasChanged();
try
{
var confirmed = await AuthService.Confirm2FaAsync(setup2FaUser.Id, setup2FaCode);
if (confirmed)
{
setup2FaUser.TwoFactorEnabled = true;
setup2FaUser.TwoFactorSecret = setup2FaSecret;
if (setup2FaEnforceAfterConfirm)
{
setup2FaUser.TwoFactorEnforced = true;
}
successMessage = $"2FA für '{setup2FaUser.Username}' wurde erfolgreich eingerichtet.";
show2FaSetupModal = false;
Close2FaSetupModal();
}
else
{
modalError = "Ungültiger Code. Bitte versuchen Sie es erneut.";
}
}
catch (Exception ex)
{
modalError = ex.Message;
}
finally
{
isSaving = false;
}
}
private void Close2FaSetupModal()
{
if (!isSaving)
{
show2FaSetupModal = false;
setup2FaUser = null;
setup2FaSecret = null;
setup2FaQrDataUrl = null;
setup2FaCode = null;
setup2FaEnforceAfterConfirm = false;
modalError = null;
}
}
#endregion
#region Helper Methods
private static string GetRoleName(UserRole role) => role switch
{
UserRole.Admin => "Administrator",
UserRole.Worker => "Mitarbeiter",
UserRole.User => "Benutzer",
_ => ""
};
private static string GetRoleClass(UserRole role) => role switch
{
UserRole.Admin => "role-admin",
UserRole.Worker => "role-worker",
UserRole.User => "role-user",
_ => ""
};
private static string FormatLastLogin(DateTime lastLogin)
{
var local = lastLogin.ToLocalTime();
var diff = DateTime.Now - local;
if (diff.TotalMinutes < 1)
return "Gerade eben";
if (diff.TotalMinutes < 60)
return $"Vor {(int)diff.TotalMinutes} Min.";
if (diff.TotalHours < 24)
return $"Vor {(int)diff.TotalHours} Std.";
if (diff.TotalDays < 7)
return $"Vor {(int)diff.TotalDays} Tagen";
return local.ToString("dd.MM.yy HH:mm");
}
#endregion
}
@@ -0,0 +1,682 @@
/* ========================================
Users Page - Scoped Component Styles
========================================
Inherits from app.css:
- .access-denied, .empty-icon, .access-denied-icon (section 19)
- .alert, .alert-danger/success/warning (section 7)
- .modal-backdrop, .app-modal, .modal-dialog/content/header/body/footer (section 10b)
- .modal-body .form-label, .required, .mb-3 (section 10b)
- .form-control, .form-select, .form-label (section 4)
- .btn, .btn-primary/secondary/danger, .btn-sm, .btn-close (section 5)
- .spinner-border (section 11)
- .invalid-feedback → validation-message (section 12)
- Reduced motion (section 17)
Only page-specific additions and overrides below.
======================================== */
/* ========================================
Page Container
======================================== */
.users-container {
padding: var(--space-6) var(--space-page-x);
max-width: 1400px;
margin: 0 auto;
}
@media (min-width: 1024px) {
.users-container {
padding: var(--space-8) var(--space-12);
}
}
@media (min-width: 1440px) {
.users-container {
padding: var(--space-10) 4rem;
}
}
/* ========================================
Header
======================================== */
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-8);
gap: var(--space-6);
}
.header-info h1 {
margin: 0;
font-family: var(--font-family-heading);
font-size: clamp(1.5rem, 3vw, 2rem);
color: var(--text-primary);
letter-spacing: -0.02em;
}
.header-subtitle {
margin: var(--space-2) 0 0 0;
font-family: var(--font-family-base);
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.header-actions {
display: flex;
gap: var(--space-3);
align-items: center;
}
.header-actions .btn {
white-space: nowrap;
}
/* ========================================
Loading & Empty States
======================================== */
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-10) var(--space-8);
color: var(--text-tertiary);
font-family: var(--font-family-base);
}
.loading-spinner .spinner-border {
width: 3rem;
height: 3rem;
color: var(--color-primary-light);
margin-bottom: var(--space-6);
}
.empty-state {
text-align: center;
padding: var(--space-10) var(--space-8);
color: var(--text-tertiary);
}
.empty-state h3 {
margin: 0 0 var(--space-3) 0;
color: var(--text-primary);
font-family: var(--font-family-base);
font-size: var(--text-xl);
}
.empty-state p {
margin: 0;
font-family: var(--font-family-base);
}
/* ========================================
Alerts (page-specific overrides)
======================================== */
.alert {
justify-content: space-between;
margin-bottom: var(--space-6);
}
.alert-dismiss {
background: none;
border: none;
color: inherit;
font-size: var(--text-2xl);
cursor: pointer;
padding: 0 0 0 var(--space-4);
opacity: 0.7;
transition: opacity var(--duration-fast) var(--ease-default);
line-height: 1;
flex-shrink: 0;
}
.alert-dismiss:hover {
opacity: 1;
}
/* ========================================
Users Table
======================================== */
.users-table {
background: var(--surface-elevated);
border-radius: var(--radius-xl);
border: 0.5px solid var(--border-default);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.table-header {
display: grid;
grid-template-columns: 1.5fr 1.5fr 0.8fr 0.7fr 0.7fr 1fr 140px;
padding: var(--space-4) var(--space-6);
background: rgba(0, 0, 0, 0.3);
border-bottom: 0.5px solid var(--border-light);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
gap: var(--space-4);
}
.table-body {
max-height: 65vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.table-body::-webkit-scrollbar {
width: 8px;
}
.table-body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
}
.table-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.table-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.table-row {
display: grid;
grid-template-columns: 1.5fr 1.5fr 0.8fr 0.7fr 0.7fr 1fr 140px;
padding: var(--space-5) var(--space-6);
border-bottom: 0.5px solid var(--border-light);
align-items: center;
gap: var(--space-4);
transition: background var(--duration-fast) var(--ease-out);
}
.table-row:last-child {
border-bottom: none;
}
.table-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.table-row.inactive {
opacity: 0.5;
}
.table-row.current-user {
background: rgba(220, 38, 38, 0.06);
}
.table-row.current-user:hover {
background: rgba(220, 38, 38, 0.1);
}
/* ========================================
Table Columns
======================================== */
.col-username,
.col-displayname,
.col-lastlogin {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-username {
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
font-size: var(--text-base);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.col-displayname {
color: var(--text-secondary);
font-family: var(--font-family-base);
font-size: var(--text-sm);
}
.col-role,
.col-status,
.col-2fa {
display: flex;
align-items: center;
}
.col-lastlogin {
color: var(--text-placeholder);
font-size: var(--text-sm);
font-family: var(--font-family-base);
}
.col-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
flex-wrap: wrap;
}
/* ========================================
Badges
======================================== */
/* Current User */
.current-user-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-2);
background: rgba(220, 38, 38, 0.15);
color: var(--color-error-light);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
/* Roles */
.role-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
}
.role-badge.role-admin {
background: rgba(220, 38, 38, 0.15);
color: var(--color-error-light);
}
.role-badge.role-worker {
background: rgba(59, 130, 246, 0.15);
color: var(--color-info);
}
.role-badge.role-user {
background: rgba(107, 114, 128, 0.15);
color: var(--text-tertiary);
}
/* Status */
.status-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
}
.status-badge.active {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success-light);
}
.status-badge.inactive {
background: rgba(107, 114, 128, 0.15);
color: var(--text-tertiary);
}
/* 2FA */
.tfa-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
}
.tfa-badge.tfa-on {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success);
border: 0.5px solid rgba(34, 197, 94, 0.3);
}
.tfa-badge.tfa-off {
background: rgba(255, 255, 255, 0.05);
color: var(--text-placeholder);
border: 0.5px solid var(--border-default);
}
/* ========================================
Action Buttons
======================================== */
.btn-icon-sm {
width: var(--touch-target-sm);
height: var(--touch-target-sm);
border: none;
background: rgba(255, 255, 255, 0.08);
color: var(--text-tertiary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.btn-icon-sm:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
transform: translateY(-1px);
}
.btn-icon-sm:active {
transform: scale(0.92);
}
.btn-icon-sm.danger:hover {
background: rgba(220, 38, 38, 0.2);
color: var(--color-error-light);
}
.btn-icon-sm.warning {
color: var(--color-warning);
}
.btn-icon-sm.warning:hover {
background: rgba(245, 158, 11, 0.15);
}
/* ========================================
Modal Content (page-specific additions)
======================================== */
/* Modal form controls — slightly larger padding than global default */
.modal-body .form-control,
.modal-body .form-select {
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-lg);
}
.modal-body .form-select option {
background: var(--surface-primary);
color: var(--text-primary);
padding: var(--space-2);
}
.modal-body .form-text {
display: block;
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--text-muted);
font-family: var(--font-family-base);
}
.modal-body .form-text.text-warning {
color: var(--color-warning);
}
.invalid-feedback {
display: block;
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-error-light);
font-family: var(--font-family-base);
}
/* Checkbox */
.form-check {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
padding: var(--space-2) 0;
}
.form-check-input {
width: 22px;
height: 22px;
accent-color: var(--color-primary);
border-radius: var(--radius-sm);
cursor: pointer;
flex-shrink: 0;
}
.form-check-label {
font-family: var(--font-family-base);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--text-base);
}
/* User Info Box (password modal) */
.user-info-box {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-lg);
font-family: var(--font-family-base);
}
.user-info-box .label {
color: var(--text-muted);
}
.user-info-box .value {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* Delete Warning */
.delete-warning {
text-align: center;
padding: var(--space-6) 0;
}
.warning-icon {
font-family: var(--font-family-emoji);
font-size: 3rem;
margin-bottom: var(--space-5);
line-height: 1;
}
.warning-text {
font-family: var(--font-family-base);
font-size: var(--text-lg);
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.warning-subtext {
font-family: var(--font-family-base);
font-size: var(--text-sm);
color: var(--text-muted);
}
/* ========================================
2FA Setup Modal
======================================== */
.tfa-setup-instructions {
font-family: var(--font-family-base);
color: var(--text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
line-height: var(--line-height-relaxed);
}
.tfa-setup-instructions p {
margin: 0;
}
.tfa-qr-container {
display: flex;
justify-content: center;
padding: var(--space-6);
margin-bottom: var(--space-5);
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius-xl);
border: 0.5px solid var(--border-light);
}
.tfa-qr-image {
width: 220px;
height: 220px;
max-width: 100%;
border-radius: var(--radius-md);
image-rendering: pixelated;
}
.tfa-manual-key {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: rgba(0, 0, 0, 0.25);
border-radius: var(--radius-md);
border: 0.5px solid var(--border-light);
margin-bottom: var(--space-6);
}
.tfa-manual-key .label {
font-family: var(--font-family-base);
font-size: var(--text-xs);
color: var(--text-placeholder);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tfa-manual-key .secret-key {
font-family: 'Courier New', monospace;
font-size: var(--text-base);
color: var(--text-primary);
letter-spacing: 0.15em;
word-break: break-all;
user-select: all;
background: none;
padding: 0;
}
.tfa-confirm-section {
margin-top: var(--space-2);
}
.tfa-confirm-input {
font-family: var(--font-family-base);
font-size: var(--text-xl);
text-align: center;
letter-spacing: 0.3em;
font-weight: var(--font-weight-semibold);
}
/* ========================================
Responsive — Mobile Card Layout
======================================== */
@media (max-width: 900px) {
.users-container {
padding: var(--space-4) var(--space-3);
padding-bottom: calc(var(--space-4) + var(--safe-area-bottom));
}
.header-row {
flex-direction: column;
align-items: stretch;
gap: var(--space-4);
}
.header-actions {
width: 100%;
}
.header-actions .btn {
flex: 1;
justify-content: center;
}
/* Hide grid header; rows become cards */
.table-header {
display: none;
}
.users-table {
border-radius: var(--radius-lg);
}
.table-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-5) var(--space-4);
}
.col-username {
flex: 1 1 100%;
font-size: var(--text-lg);
margin-bottom: 2px;
white-space: normal;
}
.col-displayname {
flex: 1 1 100%;
font-size: var(--text-sm);
margin-bottom: var(--space-2);
color: var(--text-tertiary);
white-space: normal;
}
.col-role,
.col-status,
.col-2fa {
flex: 0 1 auto;
}
.col-lastlogin {
flex: 1 1 100%;
order: 10;
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 0.5px solid var(--border-light);
font-size: var(--text-sm);
white-space: normal;
}
.col-actions {
width: auto;
flex: 0 1 auto;
order: 5;
margin-left: auto;
}
}
/* ========================================
Large Desktop Optimizations
======================================== */
@media (min-width: 1200px) {
.table-header {
grid-template-columns: 1.8fr 1.5fr 0.8fr 0.7fr 0.7fr 1fr 160px;
}
.table-row {
grid-template-columns: 1.8fr 1.5fr 0.8fr 0.7fr 0.7fr 1fr 160px;
}
.col-actions {
gap: var(--space-2);
}
.btn-icon-sm {
width: 40px;
height: 40px;
}
}
Whitespace-only changes.
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
@using Lagerbestand.Components.Layout
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -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 */ }
}
}
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Lagerbestand
@using Lagerbestand.Components
@using Lagerbestand.Components.Layout
@using Lagerbestand.Models
@using Lagerbestand.Services
+496
View File
@@ -0,0 +1,496 @@
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<CatalogItem> CatalogItems => Set<CatalogItem>();
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
public DbSet<AppUser> Users => Set<AppUser>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Vehicle> Vehicles => Set<Vehicle>();
public DbSet<StorageLocation> StorageLocations => Set<StorageLocation>();
public DbSet<TireStack> TireStacks => Set<TireStack>();
public DbSet<TireItem> TireItems => Set<TireItem>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
// Kanban entities
public DbSet<KanbanColumn> KanbanColumns => Set<KanbanColumn>();
public DbSet<KanbanLabel> KanbanLabels => Set<KanbanLabel>();
public DbSet<KanbanTask> KanbanTasks => Set<KanbanTask>();
public DbSet<KanbanTaskTodo> KanbanTaskTodos => Set<KanbanTaskTodo>();
public DbSet<KanbanTaskComment> KanbanTaskComments => Set<KanbanTaskComment>();
public DbSet<KanbanTaskNote> KanbanTaskNotes => Set<KanbanTaskNote>();
public DbSet<KanbanTaskAttachment> KanbanTaskAttachments => Set<KanbanTaskAttachment>();
public DbSet<KanbanTaskTemplate> KanbanTaskTemplates => Set<KanbanTaskTemplate>();
public DbSet<KanbanTaskTemplateTodo> KanbanTaskTemplateTodos => Set<KanbanTaskTemplateTodo>();
public DbSet<KanbanTaskLabel> KanbanTaskLabels => Set<KanbanTaskLabel>();
public DbSet<KanbanTaskAssignee> KanbanTaskAssignees => Set<KanbanTaskAssignee>();
public DbSet<KanbanTaskCustomer> KanbanTaskCustomers => Set<KanbanTaskCustomer>();
public DbSet<KanbanTaskTireStack> KanbanTaskTireStacks => Set<KanbanTaskTireStack>();
public DbSet<KanbanTaskTemplateLabel> KanbanTaskTemplateLabels => Set<KanbanTaskTemplateLabel>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// CatalogItem configuration
modelBuilder.Entity<CatalogItem>(entity =>
{
entity.ToTable("CatalogItems");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.PictureFileName).HasMaxLength(500);
entity.Property(e => e.TireSize).HasMaxLength(50);
entity.Property(e => e.Season).HasConversion<int>();
entity.Property(e => e.Type).HasConversion<int>().HasDefaultValue(CatalogItemType.Reifen);
});
// InventoryItem configuration (legacy)
modelBuilder.Entity<InventoryItem>(entity =>
{
entity.ToTable("InventoryItems");
entity.HasKey(e => e.Id);
entity.Property(e => e.QrCode).IsRequired().HasMaxLength(500);
entity.Property(e => e.Name).HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(500);
entity.HasIndex(e => e.QrCode);
entity.HasOne(e => e.CatalogItem)
.WithMany()
.HasForeignKey(e => e.CatalogItemId)
.OnDelete(DeleteBehavior.SetNull);
});
// AppUser configuration
modelBuilder.Entity<AppUser>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.Username).IsRequired().HasMaxLength(100);
entity.Property(e => e.PasswordHash).IsRequired().HasMaxLength(256);
entity.Property(e => e.DisplayName).HasMaxLength(200);
entity.Property(e => e.Role).HasConversion<int>();
entity.HasIndex(e => e.Username).IsUnique();
entity.Property(e => e.TwoFactorSecret).HasMaxLength(256);
});
// Customer configuration
modelBuilder.Entity<Customer>(entity =>
{
entity.ToTable("Customers");
entity.HasKey(e => e.Id);
entity.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
entity.Property(e => e.LastName).IsRequired().HasMaxLength(100);
entity.Property(e => e.Company).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(20);
entity.Property(e => e.Email).HasMaxLength(100);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.CustomerNumber).IsRequired().HasMaxLength(20);
entity.HasIndex(e => e.CustomerNumber).IsUnique();
entity.HasIndex(e => new { e.LastName, e.FirstName });
});
// Vehicle configuration
modelBuilder.Entity<Vehicle>(entity =>
{
entity.ToTable("Vehicles");
entity.HasKey(e => e.Id);
entity.Property(e => e.Make).IsRequired().HasMaxLength(100);
entity.Property(e => e.Model).HasMaxLength(100);
entity.Property(e => e.TireSize).IsRequired().HasMaxLength(50);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.HasIndex(e => e.CustomerId);
entity.HasIndex(e => new { e.Make, e.Model });
entity.HasIndex(e => new { e.CustomerId, e.IsPrimary });
entity.HasOne(e => e.Customer)
.WithMany(c => c.Vehicles)
.HasForeignKey(e => e.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
});
// StorageLocation configuration - ABCD code format
modelBuilder.Entity<StorageLocation>(entity =>
{
entity.ToTable("StorageLocations");
entity.HasKey(e => e.Id);
// Code is the 4-digit ABCD format (e.g., "2235")
entity.Property(e => e.Code).IsRequired().HasMaxLength(4);
// Area enum stored as int (1=Werkstatt, 2=Lager)
entity.Property(e => e.Area).HasConversion<int>();
// Row is an int (Werkstatt: 1-3, Lager: 1-5)
entity.Property(e => e.Row).IsRequired();
// Position enum stored as int (1=links oben ... 4=rechts unten)
entity.Property(e => e.Position).HasConversion<int>().IsRequired();
// SlotNumber is int (1-9)
entity.Property(e => e.SlotNumber).IsRequired();
// Concurrency token: prevents double-booking when two requests
// try to occupy/clear the same location simultaneously
entity.Property(e => e.IsOccupied).IsConcurrencyToken();
entity.Property(e => e.Notes).HasMaxLength(500);
// Indexes
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => new { e.Area, e.Row });
entity.HasIndex(e => new { e.Area, e.Row, e.Position, e.SlotNumber }).IsUnique();
entity.HasIndex(e => e.IsOccupied);
entity.HasIndex(e => e.TireStackId);
// Relationship to the occupied TireStack
entity.HasOne(e => e.OccupiedTireStack)
.WithOne()
.HasForeignKey<StorageLocation>(e => e.TireStackId)
.OnDelete(DeleteBehavior.SetNull);
});
// TireStack configuration
modelBuilder.Entity<TireStack>(entity =>
{
entity.ToTable("TireStacks");
entity.HasKey(e => e.Id);
entity.Property(e => e.TicketNumber).IsRequired().HasMaxLength(50);
entity.Property(e => e.TireSize).HasMaxLength(50);
entity.Property(e => e.TireBrand).HasMaxLength(100);
entity.Property(e => e.TireModel).HasMaxLength(100);
entity.Property(e => e.DotCode).HasMaxLength(20);
entity.Property(e => e.RimSize).HasMaxLength(20);
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.Property(e => e.Status).HasConversion<int>();
entity.Property(e => e.Season).HasConversion<int>();
entity.Property(e => e.Condition).HasConversion<int>();
entity.Property(e => e.TreadDepth).HasPrecision(4, 1);
entity.Property(e => e.ItemCount).HasDefaultValue(TireStack.TiresPerStack);
// CustomerId is optional (nullable) for inventory items without a customer
entity.Property(e => e.CustomerId).IsRequired(false);
entity.Property(e => e.VehicleId).IsRequired(false);
entity.HasIndex(e => e.TicketNumber).IsUnique();
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.CustomerId);
entity.HasIndex(e => e.VehicleId);
entity.HasIndex(e => e.StorageLocationId);
// Customer relationship - optional for inventory items
entity.HasOne(e => e.Customer)
.WithMany(c => c.TireStacks)
.HasForeignKey(e => e.CustomerId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
// Vehicle relationship - optional
entity.HasOne(e => e.Vehicle)
.WithMany(v => v.TireStacks)
.HasForeignKey(e => e.VehicleId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.StorageLocation)
.WithMany(s => s.TireStacks)
.HasForeignKey(e => e.StorageLocationId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.CatalogItem)
.WithMany()
.HasForeignKey(e => e.CatalogItemId)
.OnDelete(DeleteBehavior.SetNull);
});
// TireItem configuration
modelBuilder.Entity<TireItem>(entity =>
{
entity.ToTable("TireItems");
entity.HasKey(e => e.Id);
entity.Property(e => e.Position).HasConversion<int>();
entity.Property(e => e.Condition).HasConversion<int>();
entity.Property(e => e.TreadDepth).HasPrecision(4, 1);
entity.Property(e => e.DamageNotes).HasMaxLength(500);
entity.Property(e => e.DotCode).HasMaxLength(20);
entity.Property(e => e.PhotoFileName).HasMaxLength(500);
entity.HasIndex(e => e.TireStackId);
entity.HasOne(e => e.TireStack)
.WithMany()
.HasForeignKey(e => e.TireStackId)
.OnDelete(DeleteBehavior.Cascade);
});
// SiteSettings configuration
modelBuilder.Entity<SiteSettings>(entity =>
{
entity.ToTable("SiteSettings");
entity.HasKey(e => e.Id);
entity.Property(e => e.Key).IsRequired().HasMaxLength(100);
entity.Property(e => e.Value).IsRequired().HasMaxLength(4000);
entity.HasIndex(e => e.Key).IsUnique();
});
// ========================================
// Kanban Board Configurations
// ========================================
// KanbanColumn configuration
modelBuilder.Entity<KanbanColumn>(entity =>
{
entity.ToTable("KanbanColumns");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.Property(e => e.Color).HasMaxLength(7);
entity.Property(e => e.Icon).HasMaxLength(10);
entity.HasIndex(e => e.SortOrder);
});
// KanbanLabel configuration
modelBuilder.Entity<KanbanLabel>(entity =>
{
entity.ToTable("KanbanLabels");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(50);
entity.Property(e => e.Color).IsRequired().HasMaxLength(7);
entity.Property(e => e.Description).HasMaxLength(200);
entity.HasIndex(e => e.SortOrder);
});
// KanbanTask configuration
modelBuilder.Entity<KanbanTask>(entity =>
{
entity.ToTable("KanbanTasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasConversion<int>();
entity.HasIndex(e => e.ColumnId);
entity.HasIndex(e => e.DueDate);
entity.HasIndex(e => e.IsArchived);
entity.HasIndex(e => new { e.ColumnId, e.SortOrder });
entity.HasOne(e => e.Column)
.WithMany(c => c.Tasks)
.HasForeignKey(e => e.ColumnId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CreatedByUser)
.WithMany()
.HasForeignKey(e => e.CreatedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
// KanbanTaskTodo configuration
modelBuilder.Entity<KanbanTaskTodo>(entity =>
{
entity.ToTable("KanbanTaskTodos");
entity.HasKey(e => e.Id);
entity.Property(e => e.Text).IsRequired().HasMaxLength(500);
entity.HasIndex(e => e.TaskId);
entity.HasOne(e => e.Task)
.WithMany(t => t.Todos)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
});
// KanbanTaskComment configuration
modelBuilder.Entity<KanbanTaskComment>(entity =>
{
entity.ToTable("KanbanTaskComments");
entity.HasKey(e => e.Id);
entity.Property(e => e.Content).IsRequired();
entity.Property(e => e.CommentType).HasConversion<int>();
entity.HasIndex(e => e.TaskId);
entity.HasIndex(e => e.CreatedAt);
entity.HasOne(e => e.Task)
.WithMany(t => t.Comments)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.SetNull);
});
// KanbanTaskNote configuration
modelBuilder.Entity<KanbanTaskNote>(entity =>
{
entity.ToTable("KanbanTaskNotes");
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
entity.HasIndex(e => e.TaskId);
entity.HasOne(e => e.Task)
.WithMany(t => t.Notes)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.LastEditedByUser)
.WithMany()
.HasForeignKey(e => e.LastEditedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
// KanbanTaskAttachment configuration
modelBuilder.Entity<KanbanTaskAttachment>(entity =>
{
entity.ToTable("KanbanTaskAttachments");
entity.HasKey(e => e.Id);
entity.Property(e => e.FileName).IsRequired().HasMaxLength(255);
entity.Property(e => e.ContentType).IsRequired().HasMaxLength(100);
entity.Property(e => e.FilePath).HasMaxLength(500);
// FileContent is nullable for migration to file-based storage
entity.Property(e => e.FileContent).IsRequired(false);
entity.HasIndex(e => e.TaskId);
entity.HasOne(e => e.Task)
.WithMany(t => t.Attachments)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.UploadedByUser)
.WithMany()
.HasForeignKey(e => e.UploadedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
// KanbanTaskTemplate configuration
modelBuilder.Entity<KanbanTaskTemplate>(entity =>
{
entity.ToTable("KanbanTaskTemplates");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.TitlePattern).HasMaxLength(200);
entity.Property(e => e.DefaultPriority).HasConversion<int>();
entity.HasIndex(e => e.SortOrder);
entity.HasOne(e => e.DefaultColumn)
.WithMany()
.HasForeignKey(e => e.DefaultColumnId)
.OnDelete(DeleteBehavior.SetNull);
});
// KanbanTaskTemplateTodo configuration
modelBuilder.Entity<KanbanTaskTemplateTodo>(entity =>
{
entity.ToTable("KanbanTaskTemplateTodos");
entity.HasKey(e => e.Id);
entity.Property(e => e.Text).IsRequired().HasMaxLength(500);
entity.HasOne(e => e.Template)
.WithMany(t => t.Todos)
.HasForeignKey(e => e.TemplateId)
.OnDelete(DeleteBehavior.Cascade);
});
// KanbanTaskLabel (many-to-many)
modelBuilder.Entity<KanbanTaskLabel>(entity =>
{
entity.ToTable("KanbanTaskLabels");
entity.HasKey(e => new { e.TaskId, e.LabelId });
entity.HasOne(e => e.Task)
.WithMany(t => t.TaskLabels)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Label)
.WithMany(l => l.TaskLabels)
.HasForeignKey(e => e.LabelId)
.OnDelete(DeleteBehavior.Cascade);
});
// KanbanTaskTemplateLabel (many-to-many)
modelBuilder.Entity<KanbanTaskTemplateLabel>(entity =>
{
entity.ToTable("KanbanTaskTemplateLabels");
entity.HasKey(e => new { e.TemplateId, e.LabelId });
entity.HasOne(e => e.Template)
.WithMany(t => t.TemplateLabels)
.HasForeignKey(e => e.TemplateId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Label)
.WithMany(l => l.TemplateLabels)
.HasForeignKey(e => e.LabelId)
.OnDelete(DeleteBehavior.Cascade);
});
// KanbanTaskAssignee (many-to-many)
modelBuilder.Entity<KanbanTaskAssignee>(entity =>
{
entity.ToTable("KanbanTaskAssignees");
entity.HasKey(e => new { e.TaskId, e.UserId });
entity.HasOne(e => e.Task)
.WithMany(t => t.Assignees)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
// KanbanTaskCustomer (many-to-many)
modelBuilder.Entity<KanbanTaskCustomer>(entity =>
{
entity.ToTable("KanbanTaskCustomers");
entity.HasKey(e => new { e.TaskId, e.CustomerId });
entity.HasOne(e => e.Task)
.WithMany(t => t.TaskCustomers)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Customer)
.WithMany()
.HasForeignKey(e => e.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Vehicle)
.WithMany()
.HasForeignKey(e => e.VehicleId)
.OnDelete(DeleteBehavior.SetNull);
});
// KanbanTaskTireStack (many-to-many)
modelBuilder.Entity<KanbanTaskTireStack>(entity =>
{
entity.ToTable("KanbanTaskTireStacks");
entity.HasKey(e => new { e.TaskId, e.TireStackId });
entity.HasOne(e => e.Task)
.WithMany(t => t.TaskTireStacks)
.HasForeignKey(e => e.TaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.TireStack)
.WithMany()
.HasForeignKey(e => e.TireStackId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -0,0 +1,87 @@
namespace Lagerbestand.Exceptions;
/// <summary>
/// Base exception for storage location related errors.
/// </summary>
public class StorageLocationException : Exception
{
public StorageLocationException(string message) : base(message) { }
public StorageLocationException(string message, Exception innerException) : base(message, innerException) { }
}
/// <summary>
/// Exception thrown when a location code is invalid or malformed.
/// </summary>
public class InvalidLocationCodeException : StorageLocationException
{
public string? LocationCode { get; }
public InvalidLocationCodeException(string locationCode)
: base($"Ungültiger Lagerplatz-Code: '{locationCode}'. Das Format muss ABCD sein (z.B. 1101).")
{
LocationCode = locationCode;
}
public InvalidLocationCodeException(string locationCode, string reason)
: base($"Ungültiger Lagerplatz-Code '{locationCode}': {reason}")
{
LocationCode = locationCode;
}
}
/// <summary>
/// Exception thrown when trying to use an occupied location.
/// </summary>
public class LocationOccupiedException : StorageLocationException
{
public string LocationCode { get; }
public Guid? TireStackId { get; }
public LocationOccupiedException(string locationCode)
: base($"Der Lagerplatz '{locationCode}' ist bereits belegt.")
{
LocationCode = locationCode;
}
public LocationOccupiedException(string locationCode, Guid tireStackId)
: base($"Der Lagerplatz '{locationCode}' ist bereits mit Reifenstapel belegt.")
{
LocationCode = locationCode;
TireStackId = tireStackId;
}
}
/// <summary>
/// Exception thrown when a location is not found.
/// </summary>
public class LocationNotFoundException : StorageLocationException
{
public string? LocationCode { get; }
public Guid? LocationId { get; }
public LocationNotFoundException(string locationCode)
: base($"Lagerplatz '{locationCode}' wurde nicht gefunden.")
{
LocationCode = locationCode;
}
public LocationNotFoundException(Guid locationId)
: base($"Lagerplatz mit ID '{locationId}' wurde nicht gefunden.")
{
LocationId = locationId;
}
}
/// <summary>
/// Exception thrown when a location code already exists.
/// </summary>
public class LocationCodeExistsException : StorageLocationException
{
public string LocationCode { get; }
public LocationCodeExistsException(string locationCode)
: base($"Der Lagerplatz-Code '{locationCode}' existiert bereits.")
{
LocationCode = locationCode;
}
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="QRCoder" Version="1.7.0" />
</ItemGroup>
</Project>
+52
View File
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
public enum UserRole
{
User = 0,
Worker = 1, // Changed from Owner - limited access to tire operations and inventory
Admin = 2 // Full access to everything
}
public class AppUser
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Username { get; set; } = string.Empty;
[Required]
[MaxLength(256)]
public string PasswordHash { get; set; } = string.Empty;
[MaxLength(200)]
public string? DisplayName { get; set; }
public UserRole Role { get; set; } = UserRole.User;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
/// <summary>
/// Whether 2FA is enabled for this user (user opted in or admin enforced).
/// </summary>
public bool TwoFactorEnabled { get; set; }
/// <summary>
/// Base32-encoded TOTP secret key for authenticator apps.
/// </summary>
[MaxLength(256)]
public string? TwoFactorSecret { get; set; }
/// <summary>
/// Whether an admin has enforced 2FA for this user.
/// When true, the user cannot disable 2FA themselves.
/// </summary>
public bool TwoFactorEnforced { get; set; }
}
+88
View File
@@ -0,0 +1,88 @@
namespace Lagerbestand.Models;
/// <summary>
/// Represents the storage area type for tire storage locations.
/// The value corresponds to position A in the location code (ABCD format).
/// </summary>
public enum AreaType
{
/// <summary>
/// Werkstatt (Gebäude 1, 3 Regalreihen) - Code prefix: 1
/// </summary>
Werkstatt = 1,
/// <summary>
/// Lager (Gebäude 2, 5 Regalreihen) - Code prefix: 2
/// </summary>
Lager = 2
}
/// <summary>
/// Extension methods for AreaType enum.
/// </summary>
public static class AreaTypeExtensions
{
/// <summary>
/// Gets the German display name for the area type.
/// </summary>
public static string GetDisplayName(this AreaType areaType) => areaType switch
{
AreaType.Lager => "Lager",
AreaType.Werkstatt => "Werkstatt",
_ => areaType.ToString()
};
/// <summary>
/// Gets the single-digit code for the area type.
/// </summary>
public static string GetCode(this AreaType areaType) => ((int)areaType).ToString();
/// <summary>
/// Gets the maximum shelf row number for the building.
/// Werkstatt (Gebäude 1) has 3 rows, Lager (Gebäude 2) has 5 rows.
/// </summary>
public static int GetMaxRow(this AreaType areaType) => areaType switch
{
AreaType.Werkstatt => 3,
AreaType.Lager => 5,
_ => 1
};
/// <summary>
/// Gets a display label including the building number (e.g., "Gebäude 1 – Werkstatt").
/// </summary>
public static string GetBuildingLabel(this AreaType areaType) =>
$"Gebäude {(int)areaType} – {areaType.GetDisplayName()}";
/// <summary>
/// Parses a code digit to AreaType.
/// </summary>
/// <param name="code">Single digit code (1 or 2)</param>
/// <returns>The corresponding AreaType</returns>
/// <exception cref="ArgumentException">If code is not valid</exception>
public static AreaType FromCode(char code) => code switch
{
'1' => AreaType.Werkstatt,
'2' => AreaType.Lager,
_ => throw new ArgumentException($"Ungültiger Bereichscode: {code}. Erlaubt: 1 (Werkstatt), 2 (Lager)", nameof(code))
};
/// <summary>
/// Tries to parse a code digit to AreaType.
/// </summary>
public static bool TryFromCode(char code, out AreaType areaType)
{
areaType = default;
if (code == '1')
{
areaType = AreaType.Werkstatt;
return true;
}
if (code == '2')
{
areaType = AreaType.Lager;
return true;
}
return false;
}
}
+71
View File
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Type of catalog item
/// </summary>
public enum CatalogItemType
{
/// <summary>
/// Tire category
/// </summary>
Reifen = 0,
/// <summary>
/// Rim/wheel category
/// </summary>
Felgen = 1
}
public class CatalogItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? PictureFileName { get; set; }
/// <summary>
/// Size info - tire size for Reifen (e.g., "225/45 R17"), rim size for Felgen (e.g., "17 Zoll")
/// </summary>
[MaxLength(50)]
public string TireSize { get; set; } = string.Empty;
public TireSeason Season { get; set; } = TireSeason.Sommer;
/// <summary>
/// Category type (Reifen or Felgen)
/// </summary>
public CatalogItemType Type { get; set; } = CatalogItemType.Reifen;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Display label for the size field based on type
/// </summary>
[NotMapped]
public string SizeLabel => Type == CatalogItemType.Felgen ? "Felgengröße" : "Reifengröße";
/// <summary>
/// Display details string
/// </summary>
[NotMapped]
public string DisplayDetails => Type == CatalogItemType.Felgen
? TireSize
: $"{TireSize} | {Season}";
}
public enum TireSeason
{
Sommer = 0,
Winter = 1,
Allrad = 2
}
+86
View File
@@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a customer who stores tires at the shop.
/// A customer can have multiple vehicles.
/// </summary>
public class Customer
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(100)]
public string? FirstName { get; set; }
[Required]
[MaxLength(100)]
public string LastName { get; set; } = string.Empty;
[MaxLength(200)]
public string? Company { get; set; }
[MaxLength(20)]
public string? Phone { get; set; }
[MaxLength(100)]
public string? Email { get; set; }
[MaxLength(500)]
public string? Notes { get; set; }
/// <summary>
/// Customer number for easy identification (e.g., K-0001)
/// </summary>
[MaxLength(20)]
public string CustomerNumber { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Navigation property for customer's vehicles
/// </summary>
public ICollection<Vehicle> Vehicles { get; set; } = new List<Vehicle>();
/// <summary>
/// Navigation property for customer's stored tire stacks
/// </summary>
public ICollection<TireStack> TireStacks { get; set; } = new List<TireStack>();
/// <summary>
/// Full name of the customer
/// </summary>
[NotMapped]
public string FullName => $"{FirstName} {LastName}".Trim();
/// <summary>
/// Display name including company if available
/// </summary>
[NotMapped]
public string DisplayName => string.IsNullOrEmpty(Company)
? FullName
: $"{FullName} ({Company})";
/// <summary>
/// Gets the primary vehicle or the first vehicle if no primary is set
/// </summary>
[NotMapped]
public Vehicle? PrimaryVehicle => Vehicles
.Where(v => v.IsActive)
.OrderByDescending(v => v.IsPrimary)
.ThenBy(v => v.CreatedAt)
.FirstOrDefault();
/// <summary>
/// Gets all active vehicles for this customer
/// </summary>
[NotMapped]
public IEnumerable<Vehicle> ActiveVehicles => Vehicles.Where(v => v.IsActive);
}
@@ -0,0 +1,48 @@
namespace Lagerbestand.Models;
/// <summary>
/// File storage settings for NFS/disk-based file storage
/// </summary>
public class FileStorageSettings
{
/// <summary>
/// Base path for file storage (e.g., /mnt/nfs/attachments or C:\Storage\Attachments)
/// </summary>
public string BasePath { get; set; } = string.Empty;
/// <summary>
/// Whether file storage is configured and enabled
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Maximum file size in bytes (default: 10 MB)
/// </summary>
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// Validates if the storage path is accessible
/// </summary>
public bool IsPathAccessible()
{
if (string.IsNullOrWhiteSpace(BasePath))
return false;
try
{
return Directory.Exists(BasePath);
}
catch
{
return false;
}
}
/// <summary>
/// Gets the full path for a given relative file path
/// </summary>
public string GetFullPath(string relativePath)
{
return Path.Combine(BasePath, relativePath);
}
}
+29
View File
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
public class InventoryItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(500)]
public string QrCode { get; set; } = string.Empty;
[MaxLength(200)]
public string? Name { get; set; }
[MaxLength(500)]
public string? Description { get; set; }
public Guid? CatalogItemId { get; set; }
[ForeignKey(nameof(CatalogItemId))]
public CatalogItem? CatalogItem { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
+53
View File
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a column in the Kanban board (e.g., Backlog, ToDo, In Progress)
/// </summary>
public class KanbanColumn
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Display order of the column (0-based)
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Optional color for the column header
/// </summary>
[MaxLength(7)]
public string? Color { get; set; }
/// <summary>
/// Optional icon/emoji for the column
/// </summary>
[MaxLength(10)]
public string? Icon { get; set; }
/// <summary>
/// Whether this is the default column for new tasks
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Whether this column represents completed tasks (for archiving logic)
/// </summary>
public bool IsCompletedColumn { get; set; }
/// <summary>
/// Whether this column is active
/// </summary>
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual ICollection<KanbanTask> Tasks { get; set; } = new List<KanbanTask>();
}
+61
View File
@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a label that can be attached to Kanban tasks
/// </summary>
public class KanbanLabel
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(50)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Hex color code for the label (e.g., "#FF5733")
/// </summary>
[Required]
[MaxLength(7)]
public string Color { get; set; } = "#6B7280";
/// <summary>
/// Optional description for the label
/// </summary>
[MaxLength(200)]
public string? Description { get; set; }
/// <summary>
/// Display order for labels
/// </summary>
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual ICollection<KanbanTaskLabel> TaskLabels { get; set; } = new List<KanbanTaskLabel>();
public virtual ICollection<KanbanTaskTemplateLabel> TemplateLabels { get; set; } = new List<KanbanTaskTemplateLabel>();
}
/// <summary>
/// Predefined label colors for easy selection
/// </summary>
public static class KanbanLabelColors
{
public static readonly Dictionary<string, string> Presets = new()
{
{ "Rot", "#EF4444" },
{ "Orange", "#F97316" },
{ "Gelb", "#EAB308" },
{ "Grün", "#22C55E" },
{ "Türkis", "#14B8A6" },
{ "Blau", "#3B82F6" },
{ "Indigo", "#6366F1" },
{ "Violett", "#8B5CF6" },
{ "Pink", "#EC4899" },
{ "Grau", "#6B7280" }
};
}
+32
View File
@@ -0,0 +1,32 @@
namespace Lagerbestand.Models;
/// <summary>
/// Statistics for the Kanban dashboard
/// </summary>
public class KanbanStatistics
{
/// <summary>
/// Total number of non-archived tasks
/// </summary>
public int TotalTasks { get; set; }
/// <summary>
/// Number of tasks due today
/// </summary>
public int TasksDueToday { get; set; }
/// <summary>
/// Number of overdue tasks
/// </summary>
public int OverdueTasks { get; set; }
/// <summary>
/// Number of tasks completed this week
/// </summary>
public int CompletedThisWeek { get; set; }
/// <summary>
/// Count of tasks per column
/// </summary>
public Dictionary<Guid, int> TasksPerColumn { get; set; } = new();
}
+141
View File
@@ -0,0 +1,141 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Priority levels for Kanban tasks
/// </summary>
public enum KanbanTaskPriority
{
Low = 0,
Medium = 1,
High = 2,
Urgent = 3
}
/// <summary>
/// Represents a task/card in the Kanban board
/// </summary>
public class KanbanTask
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Rich text description of the task
/// </summary>
public string? Description { get; set; }
/// <summary>
/// The column this task belongs to
/// </summary>
public Guid ColumnId { get; set; }
/// <summary>
/// Sort order within the column
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Due date for the task
/// </summary>
public DateTime? DueDate { get; set; }
/// <summary>
/// Priority level
/// </summary>
public KanbanTaskPriority Priority { get; set; } = KanbanTaskPriority.Medium;
/// <summary>
/// Estimated time in minutes
/// </summary>
public int? EstimatedMinutes { get; set; }
/// <summary>
/// User who created the task
/// </summary>
public Guid? CreatedByUserId { get; set; }
/// <summary>
/// Whether the task is archived
/// </summary>
public bool IsArchived { get; set; }
/// <summary>
/// When the task was moved to a completed column
/// </summary>
public DateTime? CompletedAt { get; set; }
/// <summary>
/// When the task was archived
/// </summary>
public DateTime? ArchivedAt { get; set; }
/// <summary>
/// Simple notes/memo field for the task
/// </summary>
public string? NotesText { get; set; }
/// <summary>
/// Free text field for tire size notes (Reifengröße)
/// </summary>
public string? TireSizeNote { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanColumn? Column { get; set; }
public virtual AppUser? CreatedByUser { get; set; }
public virtual ICollection<KanbanTaskTodo> Todos { get; set; } = new List<KanbanTaskTodo>();
public virtual ICollection<KanbanTaskLabel> TaskLabels { get; set; } = new List<KanbanTaskLabel>();
public virtual ICollection<KanbanTaskComment> Comments { get; set; } = new List<KanbanTaskComment>();
public virtual ICollection<KanbanTaskNote> Notes { get; set; } = new List<KanbanTaskNote>();
public virtual ICollection<KanbanTaskAttachment> Attachments { get; set; } = new List<KanbanTaskAttachment>();
public virtual ICollection<KanbanTaskAssignee> Assignees { get; set; } = new List<KanbanTaskAssignee>();
public virtual ICollection<KanbanTaskCustomer> TaskCustomers { get; set; } = new List<KanbanTaskCustomer>();
public virtual ICollection<KanbanTaskTireStack> TaskTireStacks { get; set; } = new List<KanbanTaskTireStack>();
// Computed properties
public bool IsDueToday => DueDate.HasValue && DueDate.Value.Date == DateTime.UtcNow.Date && !CompletedAt.HasValue;
public bool IsOverdue => DueDate.HasValue && DueDate.Value.Date < DateTime.UtcNow.Date && !IsArchived && !CompletedAt.HasValue;
public int TodosCompleted => Todos.Count(t => t.IsCompleted);
public int TodosTotal => Todos.Count;
public string TodoProgress => TodosTotal > 0 ? $"{TodosCompleted}/{TodosTotal}" : "";
public string PriorityText => Priority switch
{
KanbanTaskPriority.Low => "Niedrig",
KanbanTaskPriority.Medium => "Mittel",
KanbanTaskPriority.High => "Hoch",
KanbanTaskPriority.Urgent => "Dringend",
_ => "Mittel"
};
public string PriorityIcon => Priority switch
{
KanbanTaskPriority.Low => "⬇️",
KanbanTaskPriority.Medium => "➡️",
KanbanTaskPriority.High => "⬆️",
KanbanTaskPriority.Urgent => "🔥",
_ => "➡️"
};
public string EstimatedTimeDisplay
{
get
{
if (!EstimatedMinutes.HasValue) return "";
var hours = EstimatedMinutes.Value / 60;
var mins = EstimatedMinutes.Value % 60;
if (hours > 0 && mins > 0) return $"{hours}h {mins}m";
if (hours > 0) return $"{hours}h";
return $"{mins}m";
}
}
}
@@ -0,0 +1,96 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a file attachment on a Kanban task
/// </summary>
public class KanbanTaskAttachment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[Required]
[MaxLength(255)]
public string FileName { get; set; } = string.Empty;
[Required]
[MaxLength(100)]
public string ContentType { get; set; } = "application/octet-stream";
/// <summary>
/// File size in bytes
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// Relative path to the file in the storage system (NFS/disk)
/// </summary>
[MaxLength(500)]
public string? FilePath { get; set; }
/// <summary>
/// Legacy: File content stored as binary (being migrated to file-based storage)
/// </summary>
[Obsolete("Use FilePath instead. This property is retained for migration purposes only.")]
public byte[]? FileContent { get; set; }
/// <summary>
/// User who uploaded the file
/// </summary>
public Guid? UploadedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? UploadedByUser { get; set; }
// Computed properties
[NotMapped]
public string FileSizeDisplay
{
get
{
if (FileSize < 1024) return $"{FileSize} B";
if (FileSize < 1024 * 1024) return $"{FileSize / 1024.0:F1} KB";
return $"{FileSize / (1024.0 * 1024.0):F1} MB";
}
}
[NotMapped]
public string FileIcon
{
get
{
var ext = Path.GetExtension(FileName).ToLowerInvariant();
return ext switch
{
".pdf" => "📄",
".doc" or ".docx" => "📝",
".xls" or ".xlsx" => "📊",
".ppt" or ".pptx" => "📽️",
".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" => "🖼️",
".zip" or ".rar" or ".7z" => "📦",
".txt" => "📃",
_ => "📎"
};
}
}
[NotMapped]
public bool IsImage => ContentType.StartsWith("image/");
/// <summary>
/// Whether the file is stored on disk (vs legacy database storage)
/// </summary>
[NotMapped]
public bool IsFileBasedStorage => !string.IsNullOrEmpty(FilePath);
/// <summary>
/// Maximum file size allowed (10 MB)
/// </summary>
public const long MaxFileSize = 10 * 1024 * 1024;
}
+45
View File
@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Type of comment (chat message or system activity)
/// </summary>
public enum KanbanCommentType
{
Chat = 0,
Activity = 1
}
/// <summary>
/// Represents a chat message or activity log entry on a Kanban task
/// </summary>
public class KanbanTaskComment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
/// <summary>
/// User who created the comment (null for system activities)
/// </summary>
public Guid? UserId { get; set; }
[Required]
public string Content { get; set; } = string.Empty;
/// <summary>
/// Whether this is a chat message or system activity
/// </summary>
public KanbanCommentType CommentType { get; set; } = KanbanCommentType.Chat;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? User { get; set; }
// Computed properties
public bool IsActivity => CommentType == KanbanCommentType.Activity;
public bool IsChat => CommentType == KanbanCommentType.Chat;
}
+36
View File
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a shared editable note on a Kanban task (like MS Teams wiki)
/// </summary>
public class KanbanTaskNote
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[Required]
[MaxLength(100)]
public string Title { get; set; } = "Notizen";
/// <summary>
/// Rich text content of the note
/// </summary>
public string Content { get; set; } = string.Empty;
public int SortOrder { get; set; }
/// <summary>
/// Last user who edited the note
/// </summary>
public Guid? LastEditedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? LastEditedByUser { get; set; }
}
@@ -0,0 +1,57 @@
namespace Lagerbestand.Models;
/// <summary>
/// Many-to-many relationship between KanbanTask and KanbanLabel
/// </summary>
public class KanbanTaskLabel
{
public Guid TaskId { get; set; }
public Guid LabelId { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual KanbanLabel? Label { get; set; }
}
/// <summary>
/// Many-to-many relationship between KanbanTask and User (assignees)
/// </summary>
public class KanbanTaskAssignee
{
public Guid TaskId { get; set; }
public Guid UserId { get; set; }
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? User { get; set; }
}
/// <summary>
/// Many-to-many relationship between KanbanTask and Customer
/// </summary>
public class KanbanTaskCustomer
{
public Guid TaskId { get; set; }
public Guid CustomerId { get; set; }
public Guid? VehicleId { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual Customer? Customer { get; set; }
public virtual Vehicle? Vehicle { get; set; }
}
/// <summary>
/// Many-to-many relationship between KanbanTask and TireStack
/// </summary>
public class KanbanTaskTireStack
{
public Guid TaskId { get; set; }
public Guid TireStackId { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual TireStack? TireStack { get; set; }
}
+92
View File
@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a task template that can be used to quickly create new tasks
/// </summary>
public class KanbanTaskTemplate
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? Description { get; set; }
/// <summary>
/// Title pattern for tasks created from this template
/// Can include placeholders like {Kunde}
/// </summary>
[MaxLength(200)]
public string TitlePattern { get; set; } = string.Empty;
/// <summary>
/// Default description for tasks
/// </summary>
public string? DefaultDescription { get; set; }
/// <summary>
/// Default column for tasks created from this template
/// </summary>
public Guid? DefaultColumnId { get; set; }
/// <summary>
/// Default priority for tasks
/// </summary>
public KanbanTaskPriority DefaultPriority { get; set; } = KanbanTaskPriority.Medium;
/// <summary>
/// Default estimated minutes
/// </summary>
public int? DefaultEstimatedMinutes { get; set; }
/// <summary>
/// Display order for templates
/// </summary>
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanColumn? DefaultColumn { get; set; }
public virtual ICollection<KanbanTaskTemplateTodo> Todos { get; set; } = new List<KanbanTaskTemplateTodo>();
public virtual ICollection<KanbanTaskTemplateLabel> TemplateLabels { get; set; } = new List<KanbanTaskTemplateLabel>();
}
/// <summary>
/// Predefined todo item for a template
/// </summary>
public class KanbanTaskTemplateTodo
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TemplateId { get; set; }
[Required]
[MaxLength(500)]
public string Text { get; set; } = string.Empty;
public int SortOrder { get; set; }
// Navigation properties
public virtual KanbanTaskTemplate? Template { get; set; }
}
/// <summary>
/// Default label for a template (many-to-many)
/// </summary>
public class KanbanTaskTemplateLabel
{
public Guid TemplateId { get; set; }
public Guid LabelId { get; set; }
// Navigation properties
public virtual KanbanTaskTemplate? Template { get; set; }
public virtual KanbanLabel? Label { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a todo/checklist item within a Kanban task
/// </summary>
public class KanbanTaskTodo
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[Required]
[MaxLength(500)]
public string Text { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
}
+113
View File
@@ -0,0 +1,113 @@
namespace Lagerbestand.Models;
/// <summary>
/// Represents the position within a shelf slot (side + level).
/// The value corresponds to position C in the location code (ABCD format).
/// </summary>
public enum ShelfPosition
{
/// <summary>
/// Links oben - Code: 1
/// </summary>
LinksOben = 1,
/// <summary>
/// Links unten - Code: 2
/// </summary>
LinksUnten = 2,
/// <summary>
/// Rechts oben - Code: 3
/// </summary>
RechtsOben = 3,
/// <summary>
/// Rechts unten - Code: 4
/// </summary>
RechtsUnten = 4
}
/// <summary>
/// Extension methods for ShelfPosition enum.
/// </summary>
public static class ShelfPositionExtensions
{
/// <summary>
/// Gets the German display name for the shelf position.
/// </summary>
public static string GetDisplayName(this ShelfPosition position) => position switch
{
ShelfPosition.LinksOben => "Links oben",
ShelfPosition.LinksUnten => "Links unten",
ShelfPosition.RechtsOben => "Rechts oben",
ShelfPosition.RechtsUnten => "Rechts unten",
_ => position.ToString()
};
/// <summary>
/// Gets a short display name (e.g., "L↑") for compact grid views.
/// </summary>
public static string GetShortName(this ShelfPosition position) => position switch
{
ShelfPosition.LinksOben => "L↑",
ShelfPosition.LinksUnten => "L↓",
ShelfPosition.RechtsOben => "R↑",
ShelfPosition.RechtsUnten => "R↓",
_ => position.ToString()
};
/// <summary>
/// Whether the position is on the left side of the shelf.
/// </summary>
public static bool IsLeft(this ShelfPosition position) =>
position is ShelfPosition.LinksOben or ShelfPosition.LinksUnten;
/// <summary>
/// Whether the position is on the top level of the shelf.
/// </summary>
public static bool IsTop(this ShelfPosition position) =>
position is ShelfPosition.LinksOben or ShelfPosition.RechtsOben;
/// <summary>
/// Gets the single-digit code for the position.
/// </summary>
public static string GetCode(this ShelfPosition position) => ((int)position).ToString();
/// <summary>
/// Parses a code digit to ShelfPosition.
/// </summary>
/// <param name="code">Single digit code (1-4)</param>
/// <exception cref="ArgumentException">If code is not valid</exception>
public static ShelfPosition FromCode(char code) => code switch
{
'1' => ShelfPosition.LinksOben,
'2' => ShelfPosition.LinksUnten,
'3' => ShelfPosition.RechtsOben,
'4' => ShelfPosition.RechtsUnten,
_ => throw new ArgumentException($"Ungültiger Positionscode: {code}. Erlaubt: 1-4", nameof(code))
};
/// <summary>
/// Tries to parse a code digit to ShelfPosition.
/// </summary>
public static bool TryFromCode(char code, out ShelfPosition position)
{
position = default;
if (code is < '1' or > '4')
return false;
position = (ShelfPosition)(code - '0');
return true;
}
/// <summary>
/// All shelf positions in display order.
/// </summary>
public static readonly ShelfPosition[] All =
[
ShelfPosition.LinksOben,
ShelfPosition.LinksUnten,
ShelfPosition.RechtsOben,
ShelfPosition.RechtsUnten
];
}
+189
View File
@@ -0,0 +1,189 @@
namespace Lagerbestand.Models;
/// <summary>
/// Site-wide settings stored in the database
/// </summary>
public class SiteSettings
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Unique key for the setting (e.g., "PromoBanner")
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// JSON-serialized value of the setting
/// </summary>
public string Value { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
/// <summary>
/// Promotion banner settings
/// </summary>
public class PromoBannerSettings
{
public bool IsEnabled { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public bool ShowContactButton { get; set; } = true;
/// <summary>
/// Check if banner should be shown based on current date and content
/// </summary>
public bool ShouldShow()
{
// Must be enabled
if (!IsEnabled) return false;
// Must have content
if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(Message))
return false;
var now = DateTime.UtcNow.Date;
// Check date range if specified
if (StartDate.HasValue && now < StartDate.Value.Date)
return false;
if (EndDate.HasValue && now > EndDate.Value.Date)
return false;
return true;
}
}
/// <summary>
/// Lexware API connection settings for customer data synchronization
/// </summary>
public class LexwareApiSettings
{
public bool IsEnabled { get; set; }
/// <summary>
/// Lexware API base URL (e.g., https://api.lexoffice.io/v1)
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// API key for authentication
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Whether automatic daily sync is enabled
/// </summary>
public bool AutoSyncEnabled { get; set; } = true;
/// <summary>
/// Time of day for automatic sync (HH:mm format)
/// </summary>
public string AutoSyncTime { get; set; } = "02:00";
/// <summary>
/// Timestamp of the last successful sync
/// </summary>
public DateTime? LastSyncAt { get; set; }
/// <summary>
/// Result message from the last sync
/// </summary>
public string? LastSyncResult { get; set; }
/// <summary>
/// Number of customers synced in last run
/// </summary>
public int LastSyncCount { get; set; }
/// <summary>
/// Whether the API connection is configured
/// </summary>
public bool IsConfigured => !string.IsNullOrWhiteSpace(BaseUrl) && !string.IsNullOrWhiteSpace(ApiKey);
}
/// <summary>
/// Two-Factor Authentication settings stored in SiteSettings
/// </summary>
public class TwoFactorSettings
{
/// <summary>
/// Global toggle: when false, no 2FA is used at all
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Issuer name shown in authenticator apps (e.g. "Reifen Späth")
/// </summary>
public string Issuer { get; set; } = "Reifen Späth Lagerverwaltung";
/// <summary>
/// Whether email fallback codes are enabled
/// </summary>
public bool EmailFallbackEnabled { get; set; }
}
/// <summary>
/// Session / long-duration usage settings (idle lock, circuit retention).
/// Editable by admins in the Status page.
/// </summary>
public class SessionSettings
{
public const int DefaultIdleTimeoutMinutes = 60;
public const int MaxIdleTimeoutMinutes = 60;
public const int MinIdleTimeoutMinutes = 1;
public const double DefaultCircuitRetentionHours = 2.0;
public const double MaxCircuitRetentionHours = 4.0;
public const double MinCircuitRetentionHours = 0.5;
/// <summary>
/// Minutes of inactivity before the browser tab shows a "Confirm to continue" lock overlay.
/// Range: 1 – 60. Default: 60.
/// </summary>
public int IdleTimeoutMinutes { get; set; } = DefaultIdleTimeoutMinutes;
/// <summary>
/// Hours a disconnected Blazor Server circuit is retained on the server
/// before being discarded (allows reconnect without state loss).
/// Range: 0.5 – 4. Default: 2. Applied at next app restart.
/// </summary>
public double CircuitRetentionHours { get; set; } = DefaultCircuitRetentionHours;
public int GetSafeIdleTimeoutMinutes()
{
if (IdleTimeoutMinutes < MinIdleTimeoutMinutes) return MinIdleTimeoutMinutes;
if (IdleTimeoutMinutes > MaxIdleTimeoutMinutes) return MaxIdleTimeoutMinutes;
return IdleTimeoutMinutes;
}
public double GetSafeCircuitRetentionHours()
{
if (CircuitRetentionHours < MinCircuitRetentionHours) return MinCircuitRetentionHours;
if (CircuitRetentionHours > MaxCircuitRetentionHours) return MaxCircuitRetentionHours;
return CircuitRetentionHours;
}
}
/// <summary>
/// SMTP settings for sending email-based 2FA codes and notifications
/// </summary>
public class SmtpSettings
{
public bool IsEnabled { get; set; }
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string FromEmail { get; set; } = string.Empty;
public string FromName { get; set; } = "Reifen Späth Lagerverwaltung";
public bool UseSsl { get; set; } = true;
public bool IsConfigured => !string.IsNullOrWhiteSpace(Host)
&& !string.IsNullOrWhiteSpace(Username)
&& !string.IsNullOrWhiteSpace(FromEmail);
}
+252
View File
@@ -0,0 +1,252 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.RegularExpressions;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a physical storage location in the warehouse.
/// Storage locations are identified by a 4-digit code in ABCD format:
/// - A: Gebäude (1=Werkstatt mit 3 Reihen, 2=Lager mit 5 Reihen)
/// - B: Regalreihe (Werkstatt: 1-3, Lager: 1-5)
/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten)
/// - D: Platz längs (1-9)
/// Example: 2235 = Lager, Reihe 2, rechts oben, Platz 5
/// </summary>
public class StorageLocation
{
/// <summary>
/// Regex pattern for valid location codes: ABCD format
/// A = 1-2 (Gebäude), B = 1-5 (Reihe), C = 1-4 (Seite/Ebene), D = 1-9 (Platz)
/// </summary>
public const string LocationCodePattern = @"^[12][1-5][1-4][1-9]$";
/// <summary>
/// Minimum row number
/// </summary>
public const int MinRow = 1;
/// <summary>
/// Maximum row number (Lager, Gebäude 2)
/// </summary>
public const int MaxRow = 5;
/// <summary>
/// Maximum row number for Werkstatt (Gebäude 1)
/// </summary>
public const int MaxRowWerkstatt = 3;
/// <summary>
/// Maximum row number for Lager (Gebäude 2)
/// </summary>
public const int MaxRowLager = 5;
/// <summary>
/// Minimum slot number (längs)
/// </summary>
public const int MinSlot = 1;
/// <summary>
/// Maximum slot number (längs)
/// </summary>
public const int MaxSlot = 9;
/// <summary>
/// Number of positions per lengthwise slot (links/rechts × oben/unten)
/// </summary>
public const int PositionsPerSlot = 4;
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Unique 4-digit location code in ABCD format (e.g., "2235").
/// This is the primary human-readable identifier.
/// </summary>
[Required]
[MaxLength(4)]
[RegularExpression(LocationCodePattern, ErrorMessage = "Ungültiges Code-Format. Erwartet: ABCD (z.B. 2235)")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Storage building (Werkstatt=1, Lager=2).
/// Corresponds to position A in the location code.
/// </summary>
[Required]
public AreaType Area { get; set; } = AreaType.Lager;
/// <summary>
/// Shelf row number within the building (Werkstatt: 1-3, Lager: 1-5).
/// Corresponds to position B in the location code.
/// </summary>
[Required]
[Range(MinRow, MaxRow, ErrorMessage = "Reihe muss zwischen 1 und 5 liegen.")]
public int Row { get; set; } = 1;
/// <summary>
/// Position within the shelf slot: side (links/rechts) and level (oben/unten).
/// Corresponds to position C in the location code.
/// </summary>
[Required]
public ShelfPosition Position { get; set; } = ShelfPosition.LinksOben;
/// <summary>
/// Lengthwise slot number within the row (1-9).
/// Corresponds to position D in the location code.
/// </summary>
[Required]
[Range(MinSlot, MaxSlot, ErrorMessage = "Platz muss zwischen 1 und 9 liegen.")]
public int SlotNumber { get; set; } = 1;
/// <summary>
/// Whether this location is currently occupied by a tire stack.
/// </summary>
public bool IsOccupied { get; set; }
/// <summary>
/// The ID of the tire stack currently stored at this location (if occupied).
/// </summary>
public Guid? TireStackId { get; set; }
/// <summary>
/// Navigation property to the tire stack at this location.
/// </summary>
[ForeignKey(nameof(TireStackId))]
public TireStack? OccupiedTireStack { get; set; }
/// <summary>
/// When the current tire stack was placed at this location.
/// </summary>
public DateTime? OccupiedSince { get; set; }
/// <summary>
/// Optional notes about this location.
/// </summary>
[MaxLength(500)]
public string? Notes { get; set; }
/// <summary>
/// Whether this location is available for new storage (not blocked).
/// </summary>
public bool IsAvailable { get; set; } = true;
/// <summary>
/// Whether this location is active in the system.
/// </summary>
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Navigation property for tire stacks stored at this location (legacy support).
/// </summary>
public ICollection<TireStack> TireStacks { get; set; } = new List<TireStack>();
/// <summary>
/// Display name showing the human-readable location.
/// </summary>
[NotMapped]
public string DisplayName => $"{Area.GetDisplayName()}, Reihe {Row}, {Position.GetDisplayName()}, Platz {SlotNumber}";
/// <summary>
/// Short display for compact views.
/// </summary>
[NotMapped]
public string ShortDisplay => $"{Area.GetDisplayName()} R{Row} {Position.GetShortName()} P{SlotNumber}";
/// <summary>
/// Full path description of the location.
/// </summary>
[NotMapped]
public string FullPath => $"{Area.GetBuildingLabel()} / Reihe {Row} / {Position.GetDisplayName()} / Platz {SlotNumber}";
/// <summary>
/// Current occupancy count (number of stacks stored).
/// </summary>
[NotMapped]
public int CurrentOccupancy => IsOccupied ? 1 : 0;
/// <summary>
/// Capacity is always 1 for the new system (one stack per location).
/// </summary>
[NotMapped]
public int Capacity => 1;
/// <summary>
/// Generates the location code from the component parts.
/// </summary>
public void GenerateCode()
{
Code = $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
}
/// <summary>
/// Parses the location code and populates the Area, Row, Position, and SlotNumber properties.
/// </summary>
/// <returns>True if parsing was successful</returns>
public bool ParseCode()
{
if (string.IsNullOrWhiteSpace(Code) || Code.Length != 4)
return false;
if (!Regex.IsMatch(Code, LocationCodePattern))
return false;
if (!AreaTypeExtensions.TryFromCode(Code[0], out var area))
return false;
if (!int.TryParse(Code[1].ToString(), out var row) || row < MinRow || row > area.GetMaxRow())
return false;
if (!ShelfPositionExtensions.TryFromCode(Code[2], out var position))
return false;
if (!int.TryParse(Code[3].ToString(), out var slot) || slot < MinSlot || slot > MaxSlot)
return false;
Area = area;
Row = row;
Position = position;
SlotNumber = slot;
return true;
}
/// <summary>
/// Validates that the current Code matches the Area, Row, Position, and SlotNumber values.
/// </summary>
public bool ValidateCodeConsistency()
{
var expectedCode = $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
return Code == expectedCode;
}
/// <summary>
/// Assigns a tire stack to this location.
/// </summary>
/// <param name="tireStackId">The ID of the tire stack to assign</param>
/// <exception cref="InvalidOperationException">If location is already occupied</exception>
public void AssignTireStack(Guid tireStackId)
{
if (IsOccupied)
throw new InvalidOperationException($"Lagerplatz {Code} ist bereits belegt.");
TireStackId = tireStackId;
IsOccupied = true;
OccupiedSince = DateTime.UtcNow;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Clears the tire stack from this location.
/// </summary>
public void ClearLocation()
{
TireStackId = null;
OccupiedTireStack = null;
IsOccupied = false;
OccupiedSince = null;
UpdatedAt = DateTime.UtcNow;
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Tire position in a stack
/// </summary>
public enum TirePosition
{
FrontLeft = 0,
FrontRight = 1,
RearLeft = 2,
RearRight = 3
}
/// <summary>
/// Tire condition rating
/// </summary>
public enum TireCondition
{
Excellent = 0, // Like new
Good = 1, // Normal wear
Fair = 2, // Noticeable wear but safe
Poor = 3, // Should be replaced soon
Critical = 4 // Unsafe, needs replacement
}
/// <summary>
/// Represents an individual tire within a tire stack.
/// Allows detailed tracking of each tire's condition.
/// A TireStack always has exactly 4 TireItems.
/// </summary>
public class TireItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The tire stack this item belongs to
/// </summary>
public Guid TireStackId { get; set; }
[ForeignKey(nameof(TireStackId))]
public TireStack? TireStack { get; set; }
/// <summary>
/// Position of this tire in the stack
/// </summary>
public TirePosition Position { get; set; }
/// <summary>
/// Individual tread depth for this tire in mm
/// </summary>
[Range(0, 10)]
public decimal? TreadDepth { get; set; }
/// <summary>
/// Condition rating for this tire
/// </summary>
public TireCondition Condition { get; set; } = TireCondition.Good;
/// <summary>
/// Any damage or issues noted
/// </summary>
[MaxLength(500)]
public string? DamageNotes { get; set; }
/// <summary>
/// Individual DOT code if different from stack
/// </summary>
[MaxLength(20)]
public string? DotCode { get; set; }
/// <summary>
/// Photo filename for tire condition documentation
/// </summary>
[MaxLength(500)]
public string? PhotoFileName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Position display text in German
/// </summary>
[NotMapped]
public string PositionText => Position switch
{
TirePosition.FrontLeft => "Vorne Links",
TirePosition.FrontRight => "Vorne Rechts",
TirePosition.RearLeft => "Hinten Links",
TirePosition.RearRight => "Hinten Rechts",
_ => "Unbekannt"
};
/// <summary>
/// Condition display text in German
/// </summary>
[NotMapped]
public string ConditionText => Condition switch
{
TireCondition.Excellent => "Sehr gut",
TireCondition.Good => "Gut",
TireCondition.Fair => "Ausreichend",
TireCondition.Poor => "Mangelhaft",
TireCondition.Critical => "Kritisch",
_ => "Unbekannt"
};
}
+259
View File
@@ -0,0 +1,259 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Status of a tire stack in the storage system
/// </summary>
public enum TireStackStatus
{
/// <summary>
/// Tires are currently stored in the warehouse
/// </summary>
Stored = 0,
/// <summary>
/// Tires have been picked up by the customer
/// </summary>
PickedUp = 1,
/// <summary>
/// Tires have been disposed/recycled
/// </summary>
Disposed = 2,
/// <summary>
/// Tires are reserved for pickup
/// </summary>
Reserved = 3,
/// <summary>
/// Tires are in inventory (shop stock, not customer-owned)
/// </summary>
Inventory = 4
}
/// <summary>
/// Represents a stack of exactly 4 tires as a single indivisible unit.
/// A stack is always 4 identical tires - there is no variable count.
/// This is the main entity that tracks customer tire storage and shop inventory.
/// </summary>
public class TireStack
{
/// <summary>
/// Number of tires in a stack - always 4
/// </summary>
public const int TiresPerStack = 4;
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Custom item count for inventory (default: 4 = standard stack).
/// Only used for inventory items. Customer stacks always use TiresPerStack.
/// </summary>
[Range(1, 99)]
public int ItemCount { get; set; } = TiresPerStack;
/// <summary>
/// Unique identifier/ticket number for this tire stack (e.g., "TS-2024-0001")
/// This serves as the primary human-readable identifier.
/// </summary>
[Required]
[MaxLength(50)]
public string TicketNumber { get; set; } = string.Empty;
/// <summary>
/// Customer who owns these tires (null for shop inventory)
/// </summary>
public Guid? CustomerId { get; set; }
[ForeignKey(nameof(CustomerId))]
public Customer? Customer { get; set; }
/// <summary>
/// Vehicle these tires belong to (null for shop inventory or legacy data)
/// </summary>
public Guid? VehicleId { get; set; }
[ForeignKey(nameof(VehicleId))]
public Vehicle? Vehicle { get; set; }
/// <summary>
/// Current storage location (null if picked up)
/// </summary>
public Guid? StorageLocationId { get; set; }
[ForeignKey(nameof(StorageLocationId))]
public StorageLocation? StorageLocation { get; set; }
/// <summary>
/// Tire category/type (used for inventory categorization)
/// </summary>
public Guid? CatalogItemId { get; set; }
[ForeignKey(nameof(CatalogItemId))]
public CatalogItem? CatalogItem { get; set; }
/// <summary>
/// Current status of the tire stack
/// </summary>
public TireStackStatus Status { get; set; } = TireStackStatus.Stored;
/// <summary>
/// Tire size (e.g., "225/45R17")
/// </summary>
[MaxLength(50)]
public string? TireSize { get; set; }
/// <summary>
/// Tire brand (e.g., "Michelin", "Continental")
/// </summary>
[MaxLength(100)]
public string? TireBrand { get; set; }
/// <summary>
/// Tire model (e.g., "Pilot Sport 4", "WinterContact TS 860")
/// </summary>
[MaxLength(100)]
public string? TireModel { get; set; }
/// <summary>
/// Season type
/// </summary>
public TireSeason Season { get; set; } = TireSeason.Sommer;
/// <summary>
/// Tread depth in mm (average of all 4 tires)
/// </summary>
[Range(0, 10)]
public decimal? TreadDepth { get; set; }
/// <summary>
/// DOT/manufacturing date code (e.g., "2523" = week 25 of 2023)
/// </summary>
[MaxLength(20)]
public string? DotCode { get; set; }
/// <summary>
/// Whether tires are mounted on rims
/// </summary>
public bool WithRims { get; set; } = true;
/// <summary>
/// Rim size if with rims (e.g., "17 Zoll")
/// </summary>
[MaxLength(20)]
public string? RimSize { get; set; }
/// <summary>
/// TPMS sensors present
/// </summary>
public bool HasTpms { get; set; }
/// <summary>
/// Tire condition rating
/// </summary>
public TireCondition Condition { get; set; } = TireCondition.Good;
/// <summary>
/// Additional notes about the tire condition or special requirements
/// </summary>
[MaxLength(1000)]
public string? Notes { get; set; }
/// <summary>
/// Date when tires were stored
/// </summary>
public DateTime StoredAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Date when tires were picked up
/// </summary>
public DateTime? PickedUpAt { get; set; }
/// <summary>
/// Expected pickup date (next season change)
/// </summary>
public DateTime? ExpectedPickupDate { get; set; }
/// <summary>
/// User who stored the tires
/// </summary>
public Guid? StoredByUserId { get; set; }
/// <summary>
/// User who processed the pickup
/// </summary>
public Guid? PickedUpByUserId { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Whether this is shop inventory (not customer-owned)
/// </summary>
[NotMapped]
public bool IsInventory => CustomerId == null;
/// <summary>
/// Display name - customer name or category name for inventory
/// </summary>
[NotMapped]
public string DisplayName => Customer?.FullName ?? CatalogItem?.Name ?? "Lagerbestand";
/// <summary>
/// Summary description for display
/// </summary>
[NotMapped]
public string Summary
{
get
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(TireBrand)) parts.Add(TireBrand);
if (!string.IsNullOrEmpty(TireSize)) parts.Add(TireSize);
parts.Add(Season.ToString());
if (WithRims) parts.Add("mit Felgen");
return string.Join(" | ", parts);
}
}
/// <summary>
/// Status display text in German
/// </summary>
[NotMapped]
public string StatusText => Status switch
{
TireStackStatus.Stored => "Eingelagert",
TireStackStatus.PickedUp => "Abgeholt",
TireStackStatus.Disposed => "Entsorgt",
TireStackStatus.Reserved => "Reserviert",
TireStackStatus.Inventory => "Lagerbestand",
_ => "Unbekannt"
};
/// <summary>
/// Days stored (for billing purposes)
/// </summary>
[NotMapped]
public int DaysStored => (PickedUpAt ?? DateTime.UtcNow).Subtract(StoredAt).Days;
/// <summary>
/// Condition display text in German
/// </summary>
[NotMapped]
public string ConditionText => Condition switch
{
TireCondition.Excellent => "Sehr gut",
TireCondition.Good => "Gut",
TireCondition.Fair => "Ausreichend",
TireCondition.Poor => "Mangelhaft",
TireCondition.Critical => "Kritisch",
_ => "Unbekannt"
};
}
+92
View File
@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a vehicle owned by a customer.
/// A customer can have multiple vehicles, each with their own tire stacks.
/// </summary>
public class Vehicle
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The customer who owns this vehicle
/// </summary>
public Guid CustomerId { get; set; }
[ForeignKey(nameof(CustomerId))]
public Customer? Customer { get; set; }
/// <summary>
/// Vehicle manufacturer (e.g., "BMW", "Mercedes", "VW")
/// </summary>
[Required]
[MaxLength(100)]
public string Make { get; set; } = string.Empty;
/// <summary>
/// Vehicle model (e.g., "3er", "C-Klasse", "Golf")
/// </summary>
[MaxLength(100)]
public string? Model { get; set; }
/// <summary>
/// Tire size for this vehicle (e.g., "225/45 R17")
/// </summary>
[Required]
[MaxLength(50)]
public string TireSize { get; set; } = string.Empty;
/// <summary>
/// Additional notes about the vehicle
/// </summary>
[MaxLength(500)]
public string? Notes { get; set; }
/// <summary>
/// Whether this is the customer's primary/default vehicle
/// </summary>
public bool IsPrimary { get; set; }
/// <summary>
/// Whether the vehicle is active (not deleted)
/// </summary>
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Navigation property for tire stacks stored for this vehicle
/// </summary>
public ICollection<TireStack> TireStacks { get; set; } = new List<TireStack>();
/// <summary>
/// Display name combining make, model, and tire size
/// </summary>
[NotMapped]
public string DisplayName
{
get
{
var name = Make;
if (!string.IsNullOrEmpty(Model))
name += $" {Model}";
if (!string.IsNullOrEmpty(TireSize))
name += $" ({TireSize})";
return name;
}
}
/// <summary>
/// Short display showing just make (for compact views)
/// </summary>
[NotMapped]
public string ShortName => !string.IsNullOrEmpty(Model)
? $"{Make} {Model}"
: Make;
}
+277
View File
@@ -0,0 +1,277 @@
using Lagerbestand.Components;
using Lagerbestand.Data;
using Lagerbestand.Services;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Configure Kestrel to listen on all interfaces in production
if (!builder.Environment.IsDevelopment())
{
builder.WebHost.ConfigureKestrel(serverOptions =>
{
// Listen on all network interfaces on port 5000
serverOptions.ListenAnyIP(5000);
});
}
// Add Razor Components with interactive server-side rendering
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Circuit options — keep disconnected circuits alive longer to survive
// idle workshop tablets / brief network drops. Retention is read from
// SessionSettings (editable in Status page); default = 2 hours.
// Requires a restart to take effect after changing in Status.
var sessionRetentionHours = Lagerbestand.Models.SessionSettings.DefaultCircuitRetentionHours;
try
{
var raw = builder.Configuration["SessionSettings:CircuitRetentionHours"];
if (!string.IsNullOrWhiteSpace(raw) && double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var cfgHours))
{
sessionRetentionHours = cfgHours;
}
}
catch { /* ignore — use default */ }
// Clamp to safe range 0.5h – 4h
if (sessionRetentionHours < Lagerbestand.Models.SessionSettings.MinCircuitRetentionHours)
sessionRetentionHours = Lagerbestand.Models.SessionSettings.MinCircuitRetentionHours;
if (sessionRetentionHours > Lagerbestand.Models.SessionSettings.MaxCircuitRetentionHours)
sessionRetentionHours = Lagerbestand.Models.SessionSettings.MaxCircuitRetentionHours;
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(sessionRetentionHours);
options.DisconnectedCircuitMaxRetained = 200;
options.JSInteropDefaultCallTimeout = TimeSpan.FromMinutes(3);
options.DetailedErrors = builder.Environment.IsDevelopment();
});
// Response compression for better Core Web Vitals (production only)
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"application/javascript",
"text/css",
"application/json",
"text/html",
"image/svg+xml"
});
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = System.IO.Compression.CompressionLevel.Fastest;
});
// Configure HSTS (HTTP Strict Transport Security)
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
// Configure MariaDB/MySQL connection with robust settings
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
// Use explicit MariaDB version to avoid connection during startup
// MariaDB 10.x is common for most hosting providers
var serverVersion = new MariaDbServerVersion(new Version(10, 6, 0));
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseMySql(connectionString, serverVersion, mySqlOptions =>
{
// Connection resiliency for production
mySqlOptions.EnableRetryOnFailure(
maxRetryCount: 10,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
// Command timeout for slow connections
mySqlOptions.CommandTimeout(60);
// Enable string comparison fix for MySQL
mySqlOptions.EnableStringComparisonTranslations();
});
// Enable detailed errors only in development
if (builder.Environment.IsDevelopment())
{
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging();
}
});
// Register application services
builder.Services.AddScoped<ICatalogService, CatalogService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<ITwoFactorService, TwoFactorService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<UserSessionService>();
builder.Services.AddScoped<ISiteSettingsService, SiteSettingsService>();
builder.Services.AddScoped<IFileStorageService, FileStorageService>();
builder.Services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
// Register tire storage management services
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<ILocationCodeService, LocationCodeService>();
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
builder.Services.AddScoped<ITireStackService, TireStackService>();
// Register Kanban service
builder.Services.AddScoped<IKanbanService, KanbanService>();
// Lexware API sync service
builder.Services.AddHttpClient("LexwareApi");
builder.Services.AddScoped<ILexwareSyncService, LexwareSyncService>();
builder.Services.AddHostedService<LexwareSyncBackgroundService>();
var app = builder.Build();
// Configure the HTTP request pipeline
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// HTTPS redirection and HSTS for production
app.UseHttpsRedirection();
app.UseHsts();
// Enable response compression only in production
// (Disabled in development to allow Browser Link/Hot Reload script injection)
app.UseResponseCompression();
}
// Security headers to prevent clickjacking, MIME sniffing, and control referrer/permissions
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (IsPrivateOrUtilityPath(context.Request.Path))
{
context.Response.Headers.Append("X-Robots-Tag", "noindex, nofollow, noarchive");
}
await next();
});
static bool IsPrivateOrUtilityPath(PathString path)
{
return path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/login", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/tire-storage", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/tire-checkin", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/tire-checkout", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/customers", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/storage-locations", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/users", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/status", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/weather", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/_blazor", StringComparison.OrdinalIgnoreCase);
}
// Static files with caching headers for performance
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
// Cache static assets for 1 year (immutable) - production only
if (!app.Environment.IsDevelopment())
{
if (ctx.File.Name.EndsWith(".css") ||
ctx.File.Name.EndsWith(".js") ||
ctx.File.Name.EndsWith(".woff2") ||
ctx.File.Name.EndsWith(".woff") ||
ctx.File.Name.EndsWith(".png") ||
ctx.File.Name.EndsWith(".jpg") ||
ctx.File.Name.EndsWith(".svg"))
{
ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000, immutable");
}
else if (ctx.File.Name.EndsWith(".html") ||
ctx.File.Name.EndsWith(".xml") ||
ctx.File.Name.EndsWith(".txt"))
{
ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=3600");
}
}
}
});
app.UseAntiforgery();
// Initialize database on startup
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<IDatabaseInitializer>();
await initializer.InitializeAsync();
// Apply DB-stored circuit retention (overrides config default).
// Effective on next restart after saving in the Status page.
try
{
var settingsService = scope.ServiceProvider.GetRequiredService<ISiteSettingsService>();
var sessionSettings = await settingsService.GetSessionSettingsAsync();
var effectiveHours = sessionSettings.GetSafeCircuitRetentionHours();
var circuitOptions = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Components.Server.CircuitOptions>>().Value;
circuitOptions.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(effectiveHours);
}
catch
{
// Non-fatal — use compile-time default
}
}
// Session settings endpoint — consumed by app-interop.js to configure
// the idle-lock timeout in the browser without needing a JS-interop call.
app.MapGet("/api/session-settings", async (ISiteSettingsService settingsService) =>
{
var s = await settingsService.GetSessionSettingsAsync();
return Results.Json(new
{
idleTimeoutMinutes = s.GetSafeIdleTimeoutMinutes(),
circuitRetentionHours = s.GetSafeCircuitRetentionHours()
});
});
// Catalog image endpoint (serves images from NFS/file storage)
app.MapGet("/api/catalog-image/{*relativePath}", async (string relativePath, IFileStorageService fileStorage) =>
{
if (string.IsNullOrWhiteSpace(relativePath))
return Results.NotFound();
var fileBytes = await fileStorage.ReadFileAsync(relativePath);
if (fileBytes == null)
return Results.NotFound();
var extension = Path.GetExtension(relativePath).ToLowerInvariant();
var contentType = extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "application/octet-stream"
};
return Results.File(fileBytes, contentType);
});
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>C:\local\Neuer Ordner</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<ProjectGuid>760ca6f8-7ba8-4c6f-b464-f5aed2329aeb</ProjectGuid>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
</Project>
@@ -0,0 +1,47 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:19896",
"sslPort": 44334
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5158",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7206;http://localhost:5158",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Production": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Production"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+477
View File
@@ -0,0 +1,477 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace Lagerbestand.Services;
public interface IAuthService
{
Task<AppUser?> LoginAsync(string username, string password);
Task<AppUser?> AuthenticateAsync(string username, string password);
Task<AppUser?> GetUserByIdAsync(Guid id);
Task<List<AppUser>> GetAllUsersAsync();
Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role);
Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive);
Task<bool> ChangePasswordAsync(Guid id, string newPassword);
Task DeleteUserAsync(Guid id);
Task EnsureAdminExistsAsync();
Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null);
Task<int> GetActiveAdminCountAsync();
(bool IsValid, string? ErrorMessage) ValidatePassword(string password);
(bool IsValid, string? ErrorMessage) ValidateUsername(string username);
// 2FA methods
Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId);
Task<bool> Confirm2FaAsync(Guid userId, string code);
Task Disable2FaAsync(Guid userId);
Task Reset2FaAsync(Guid userId);
Task SetEnforce2FaAsync(Guid userId, bool enforce);
}
public class AuthService : IAuthService
{
private const string LegacyPasswordSalt = "ReifenSpaethSalt2025";
private const int Pbkdf2Iterations = 100_000;
private const int SaltSize = 16;
private const int HashSize = 32;
private const int MinPasswordLength = 4;
private const int MaxPasswordLength = 128;
private const int MinUsernameLength = 3;
private const int MaxUsernameLength = 50;
private const int MaxFailedLoginAttempts = 5;
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> FailedLoginAttempts = new();
private readonly AppDbContext _context;
private readonly IConfiguration _configuration;
private readonly ILogger<AuthService> _logger;
private readonly ITwoFactorService _twoFactorService;
private readonly ISiteSettingsService _siteSettingsService;
public AuthService(AppDbContext context, IConfiguration configuration, ILogger<AuthService> logger,
ITwoFactorService twoFactorService, ISiteSettingsService siteSettingsService)
{
_context = context;
_configuration = configuration;
_logger = logger;
_twoFactorService = twoFactorService;
_siteSettingsService = siteSettingsService;
}
public async Task<AppUser?> LoginAsync(string username, string password)
{
var lockoutKey = username.Trim().ToLowerInvariant();
if (IsLockedOut(lockoutKey))
{
_logger.LogWarning("Login blocked due to too many failed attempts for username: {Username}", username);
return null;
}
var user = await GetActiveUserByUsernameAsync(username);
if (user == null || !VerifyPassword(password, user.PasswordHash))
{
RegisterFailedAttempt(lockoutKey);
_logger.LogWarning("Failed login attempt for username: {Username}", username);
return null;
}
FailedLoginAttempts.TryRemove(lockoutKey, out _);
// Re-hash legacy SHA256 passwords to PBKDF2
await ReHashIfLegacyAsync(user, password);
user.LastLoginAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("User {Username} logged in successfully", username);
return user;
}
private static bool IsLockedOut(string key)
{
if (FailedLoginAttempts.TryGetValue(key, out var entry))
{
if (entry.Count >= MaxFailedLoginAttempts && DateTime.UtcNow - entry.LastAttempt < LockoutDuration)
return true;
if (DateTime.UtcNow - entry.LastAttempt >= LockoutDuration)
FailedLoginAttempts.TryRemove(key, out _);
}
return false;
}
private static void RegisterFailedAttempt(string key)
{
FailedLoginAttempts.AddOrUpdate(key,
_ => (1, DateTime.UtcNow),
(_, existing) => (existing.Count + 1, DateTime.UtcNow));
}
public async Task<AppUser?> AuthenticateAsync(string username, string password)
{
var user = await GetActiveUserByUsernameAsync(username);
if (user == null || !VerifyPassword(password, user.PasswordHash))
return null;
return user;
}
public async Task<AppUser?> GetUserByIdAsync(Guid id)
{
return await _context.Users.FindAsync(id);
}
public async Task<List<AppUser>> GetAllUsersAsync()
{
return await _context.Users
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null)
{
var normalizedUsername = username.Trim().ToLowerInvariant();
var query = _context.Users.Where(u => u.Username.ToLower() == normalizedUsername);
if (excludeUserId.HasValue)
query = query.Where(u => u.Id != excludeUserId.Value);
return await query.AnyAsync();
}
public async Task<int> GetActiveAdminCountAsync()
{
return await _context.Users
.CountAsync(u => u.Role == UserRole.Admin && u.IsActive);
}
public (bool IsValid, string? ErrorMessage) ValidatePassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
return (false, "Passwort ist erforderlich.");
if (password.Length < MinPasswordLength)
return (false, $"Passwort muss mindestens {MinPasswordLength} Zeichen lang sein.");
if (password.Length > MaxPasswordLength)
return (false, $"Passwort darf maximal {MaxPasswordLength} Zeichen lang sein.");
return (true, null);
}
public (bool IsValid, string? ErrorMessage) ValidateUsername(string username)
{
if (string.IsNullOrWhiteSpace(username))
return (false, "Benutzername ist erforderlich.");
var trimmed = username.Trim();
if (trimmed.Length < MinUsernameLength)
return (false, $"Benutzername muss mindestens {MinUsernameLength} Zeichen lang sein.");
if (trimmed.Length > MaxUsernameLength)
return (false, $"Benutzername darf maximal {MaxUsernameLength} Zeichen lang sein.");
// Allow only alphanumeric, dots, underscores, hyphens
if (!trimmed.All(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-'))
return (false, "Benutzername darf nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten.");
return (true, null);
}
public async Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role)
{
var usernameValidation = ValidateUsername(username);
if (!usernameValidation.IsValid)
throw new InvalidOperationException(usernameValidation.ErrorMessage);
var passwordValidation = ValidatePassword(password);
if (!passwordValidation.IsValid)
throw new InvalidOperationException(passwordValidation.ErrorMessage);
var normalizedUsername = username.Trim();
if (await UsernameExistsAsync(normalizedUsername))
throw new InvalidOperationException($"Benutzer '{normalizedUsername}' existiert bereits.");
var user = new AppUser
{
Username = normalizedUsername,
PasswordHash = HashPassword(password),
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
Role = role,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_logger.LogInformation("User {Username} created with role {Role}", normalizedUsername, role);
return user;
}
public async Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive)
{
var user = await _context.Users.FindAsync(id)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
// Prevent deactivating or demoting the last admin
if (user.Role == UserRole.Admin && (role != UserRole.Admin || !isActive))
{
var adminCount = await GetActiveAdminCountAsync();
if (adminCount <= 1)
throw new InvalidOperationException("Der letzte aktive Administrator kann nicht deaktiviert oder zu einer anderen Rolle geändert werden.");
}
// Update username if provided and different
if (!string.IsNullOrWhiteSpace(username))
{
var newUsername = username.Trim();
if (!newUsername.Equals(user.Username, StringComparison.OrdinalIgnoreCase))
{
var usernameValidation = ValidateUsername(newUsername);
if (!usernameValidation.IsValid)
throw new InvalidOperationException(usernameValidation.ErrorMessage);
if (await UsernameExistsAsync(newUsername, id))
throw new InvalidOperationException($"Benutzer '{newUsername}' existiert bereits.");
user.Username = newUsername;
}
}
user.DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim();
user.Role = role;
user.IsActive = isActive;
await _context.SaveChangesAsync();
_logger.LogInformation("User {UserId} updated", id);
return user;
}
public async Task<bool> ChangePasswordAsync(Guid id, string newPassword)
{
var passwordValidation = ValidatePassword(newPassword);
if (!passwordValidation.IsValid)
throw new InvalidOperationException(passwordValidation.ErrorMessage);
var user = await _context.Users.FindAsync(id);
if (user == null)
return false;
user.PasswordHash = HashPassword(newPassword);
await _context.SaveChangesAsync();
_logger.LogInformation("Password changed for user {UserId}", id);
return true;
}
public async Task DeleteUserAsync(Guid id)
{
var user = await _context.Users.FindAsync(id);
if (user == null)
return;
// Prevent deleting the last admin
if (user.Role == UserRole.Admin && user.IsActive)
{
var adminCount = await GetActiveAdminCountAsync();
if (adminCount <= 1)
throw new InvalidOperationException("Der letzte aktive Administrator kann nicht gelöscht werden.");
}
// Soft delete
user.IsActive = false;
await _context.SaveChangesAsync();
_logger.LogInformation("User {UserId} ({Username}) deactivated", id, user.Username);
}
public async Task EnsureAdminExistsAsync()
{
try
{
var adminUsername = _configuration["AdminUser:Username"] ?? "admin";
var adminPassword = _configuration["AdminUser:Password"] ?? "admin123";
var adminDisplayName = _configuration["AdminUser:DisplayName"] ?? "Administrator";
var existingAdmin = await _context.Users
.FirstOrDefaultAsync(u => u.Username.ToLower() == adminUsername.ToLower());
if (existingAdmin == null)
{
var admin = new AppUser
{
Username = adminUsername,
PasswordHash = HashPassword(adminPassword),
DisplayName = adminDisplayName,
Role = UserRole.Admin,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(admin);
await _context.SaveChangesAsync();
_logger.LogInformation("Admin user '{Username}' created.", adminUsername);
}
else
{
existingAdmin.PasswordHash = HashPassword(adminPassword);
existingAdmin.DisplayName = adminDisplayName;
existingAdmin.Role = UserRole.Admin;
existingAdmin.IsActive = true;
await _context.SaveChangesAsync();
_logger.LogInformation("Admin user '{Username}' updated.", adminUsername);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating/updating admin user.");
}
}
private async Task<AppUser?> GetActiveUserByUsernameAsync(string username)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower() && u.IsActive);
}
private static string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
Pbkdf2Iterations,
HashAlgorithmName.SHA256,
HashSize);
return $"{Pbkdf2Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
private static bool VerifyPassword(string password, string storedHash)
{
// New PBKDF2 format: "iterations.salt.hash"
if (storedHash.Contains('.'))
{
var parts = storedHash.Split('.', 3);
if (parts.Length == 3
&& int.TryParse(parts[0], out var iterations))
{
var salt = Convert.FromBase64String(parts[1]);
var expectedHash = Convert.FromBase64String(parts[2]);
var computedHash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
iterations,
HashAlgorithmName.SHA256,
expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(computedHash, expectedHash);
}
}
// Legacy SHA256 + hardcoded salt fallback
var legacyBytes = Encoding.UTF8.GetBytes(password + LegacyPasswordSalt);
var legacyHash = SHA256.HashData(legacyBytes);
return Convert.ToBase64String(legacyHash) == storedHash;
}
/// <summary>
/// Re-hashes a legacy password to the new PBKDF2 format on successful login.
/// </summary>
private async Task ReHashIfLegacyAsync(AppUser user, string password)
{
// If the stored hash is in legacy format (no dots), upgrade it
if (!user.PasswordHash.Contains('.'))
{
user.PasswordHash = HashPassword(password);
await _context.SaveChangesAsync();
_logger.LogInformation("Re-hashed legacy password for user {Username}", user.Username);
}
}
#region Two-Factor Authentication
public async Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
var tfSettings = await _siteSettingsService.GetTwoFactorSettingsAsync();
if (!tfSettings.IsEnabled)
throw new InvalidOperationException("Zwei-Faktor-Authentifizierung ist global deaktiviert.");
var secret = _twoFactorService.GenerateSecretKey();
var otpAuthUri = _twoFactorService.GenerateOtpAuthUri(secret, user.Username, tfSettings.Issuer);
// Store the secret but don't enable 2FA yet – user must confirm with a code first
user.TwoFactorSecret = secret;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA setup initiated for user {Username}", user.Username);
return (secret, otpAuthUri);
}
public async Task<bool> Confirm2FaAsync(Guid userId, string code)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
if (string.IsNullOrEmpty(user.TwoFactorSecret))
throw new InvalidOperationException("Kein 2FA-Secret vorhanden. Bitte 2FA zuerst einrichten.");
if (!_twoFactorService.ValidateTotpCode(user.TwoFactorSecret, code))
return false;
user.TwoFactorEnabled = true;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA confirmed and enabled for user {Username}", user.Username);
return true;
}
public async Task Disable2FaAsync(Guid userId)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
if (user.TwoFactorEnforced)
throw new InvalidOperationException("2FA wurde vom Administrator erzwungen und kann nicht deaktiviert werden.");
user.TwoFactorEnabled = false;
user.TwoFactorSecret = null;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
}
public async Task Reset2FaAsync(Guid userId)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
user.TwoFactorEnabled = false;
user.TwoFactorSecret = null;
user.TwoFactorEnforced = false;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA reset by admin for user {Username}", user.Username);
}
public async Task SetEnforce2FaAsync(Guid userId, bool enforce)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
user.TwoFactorEnforced = enforce;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA enforce set to {Enforce} for user {Username}", enforce, user.Username);
}
#endregion
}
+114
View File
@@ -0,0 +1,114 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface ICatalogService
{
Task<List<CatalogItem>> GetAllAsync();
Task<CatalogItem?> GetByIdAsync(Guid id);
Task<CatalogItem> CreateAsync(CatalogItem item);
Task<CatalogItem> UpdateAsync(CatalogItem item);
Task DeleteAsync(Guid id);
Task<string> SaveImageAsync(Stream imageStream, string fileName);
Task DeleteImageAsync(string? fileName);
}
public class CatalogService : ICatalogService
{
private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private readonly AppDbContext _context;
private readonly ILogger<CatalogService> _logger;
private readonly string _uploadPath;
public CatalogService(AppDbContext context, IWebHostEnvironment environment, ILogger<CatalogService> logger)
{
_context = context;
_logger = logger;
_uploadPath = Path.Combine(environment.WebRootPath, "uploads", "images");
Directory.CreateDirectory(_uploadPath);
}
public async Task<List<CatalogItem>> GetAllAsync()
{
return await _context.CatalogItems
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
}
public async Task<CatalogItem?> GetByIdAsync(Guid id)
{
return await _context.CatalogItems.FindAsync(id);
}
public async Task<CatalogItem> CreateAsync(CatalogItem item)
{
item.Id = Guid.NewGuid();
item.CreatedAt = DateTime.UtcNow;
_context.CatalogItems.Add(item);
await _context.SaveChangesAsync();
return item;
}
public async Task<CatalogItem> UpdateAsync(CatalogItem item)
{
var existing = await _context.CatalogItems.FindAsync(item.Id)
?? throw new InvalidOperationException("Artikel nicht gefunden");
existing.Name = item.Name;
existing.TireSize = item.TireSize;
existing.Season = item.Season;
existing.Type = item.Type;
existing.PictureFileName = item.PictureFileName;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return existing;
}
public async Task DeleteAsync(Guid id)
{
var item = await _context.CatalogItems.FindAsync(id);
if (item != null)
{
await DeleteImageAsync(item.PictureFileName);
_context.CatalogItems.Remove(item);
await _context.SaveChangesAsync();
}
}
public async Task<string> SaveImageAsync(Stream imageStream, string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedImageExtensions.Contains(extension))
throw new InvalidOperationException("Ungültiger Dateityp. Erlaubt: jpg, jpeg, png, gif, webp");
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(_uploadPath, uniqueFileName);
await using var fileStream = new FileStream(filePath, FileMode.Create);
await imageStream.CopyToAsync(fileStream);
return uniqueFileName;
}
public Task DeleteImageAsync(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
return Task.CompletedTask;
var filePath = Path.Combine(_uploadPath, fileName);
if (File.Exists(filePath))
File.Delete(filePath);
return Task.CompletedTask;
}
}
+315
View File
@@ -0,0 +1,315 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface ICustomerService
{
Task<List<Customer>> GetAllAsync(bool includeInactive = false);
Task<Customer?> GetByIdAsync(Guid id);
Task<Customer?> GetByCustomerNumberAsync(string customerNumber);
Task<List<Customer>> SearchAsync(string searchTerm);
Task<Customer?> FindByVehicleAsync(string searchTerm);
Task<Customer> CreateAsync(Customer customer);
Task<Customer> UpdateAsync(Customer customer);
Task<bool> DeleteAsync(Guid id);
Task<string> GenerateCustomerNumberAsync();
Task<int> GetStoredTireStackCountAsync(Guid customerId);
// Vehicle methods
Task<List<Vehicle>> GetVehiclesAsync(Guid customerId);
Task<Vehicle?> GetVehicleByIdAsync(Guid vehicleId);
Task<Vehicle> AddVehicleAsync(Guid customerId, Vehicle vehicle);
Task<Vehicle> UpdateVehicleAsync(Vehicle vehicle);
Task<bool> DeleteVehicleAsync(Guid vehicleId);
Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId);
Task<Customer> CreateWithVehicleAsync(Customer customer, Vehicle vehicle);
}
public class CustomerService : ICustomerService
{
private readonly AppDbContext _context;
private readonly ILogger<CustomerService> _logger;
public CustomerService(AppDbContext context, ILogger<CustomerService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<Customer>> GetAllAsync(bool includeInactive = false)
{
var query = _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.AsQueryable();
if (!includeInactive)
query = query.Where(c => c.IsActive);
return await query
.OrderBy(c => c.LastName)
.ThenBy(c => c.FirstName)
.ToListAsync();
}
public async Task<Customer?> GetByIdAsync(Guid id)
{
return await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.ThenInclude(ts => ts.StorageLocation)
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.ThenInclude(ts => ts.Vehicle)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<Customer?> GetByCustomerNumberAsync(string customerNumber)
{
return await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.FirstOrDefaultAsync(c => c.CustomerNumber == customerNumber);
}
public async Task<List<Customer>> SearchAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllAsync();
var term = searchTerm.Trim().ToLower();
return await _context.Customers
.AsNoTracking()
.AsSplitQuery()
.Include(c => c.Vehicles.Where(v => v.IsActive))
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.Where(c => c.IsActive && (
c.FirstName != null && c.FirstName.ToLower().Contains(term) ||
c.LastName.ToLower().Contains(term) ||
(c.Company != null && c.Company.ToLower().Contains(term)) ||
c.CustomerNumber.ToLower().Contains(term) ||
(c.Phone != null && c.Phone.Contains(term)) ||
// Search in vehicles
c.Vehicles.Any(v => v.IsActive && (
v.Make.ToLower().Contains(term) ||
(v.Model != null && v.Model.ToLower().Contains(term))))
))
.OrderBy(c => c.LastName)
.ThenBy(c => c.FirstName)
.Take(50)
.ToListAsync();
}
public async Task<Customer?> FindByVehicleAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return null;
var term = searchTerm.Trim().ToLower();
// Server-side search in Vehicles table by Make or Model
var matchingVehicle = await _context.Vehicles
.Include(v => v.Customer)
.Where(v => v.IsActive && v.Customer != null && v.Customer.IsActive)
.FirstOrDefaultAsync(v =>
v.Make.ToLower().Contains(term) ||
(v.Model != null && v.Model.ToLower().Contains(term)));
if (matchingVehicle?.Customer != null)
{
return await GetByIdAsync(matchingVehicle.Customer.Id);
}
return null;
}
public async Task<Customer> CreateAsync(Customer customer)
{
if (string.IsNullOrEmpty(customer.CustomerNumber))
customer.CustomerNumber = await GenerateCustomerNumberAsync();
customer.CreatedAt = DateTime.UtcNow;
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Customer created: {CustomerNumber} - {Name}",
customer.CustomerNumber, customer.FullName);
return customer;
}
public async Task<Customer> CreateWithVehicleAsync(Customer customer, Vehicle vehicle)
{
if (string.IsNullOrEmpty(customer.CustomerNumber))
customer.CustomerNumber = await GenerateCustomerNumberAsync();
customer.CreatedAt = DateTime.UtcNow;
vehicle.IsPrimary = true;
vehicle.CreatedAt = DateTime.UtcNow;
customer.Vehicles.Add(vehicle);
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Customer created with vehicle: {CustomerNumber} - {Name}, Vehicle: {Make}",
customer.CustomerNumber, customer.FullName, vehicle.Make);
return customer;
}
public async Task<Customer> UpdateAsync(Customer customer)
{
customer.UpdatedAt = DateTime.UtcNow;
_context.Customers.Update(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Customer updated: {CustomerNumber}", customer.CustomerNumber);
return customer;
}
public async Task<bool> DeleteAsync(Guid id)
{
var customer = await _context.Customers.FindAsync(id);
if (customer == null)
return false;
// Soft delete - just mark as inactive
customer.IsActive = false;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Customer deactivated: {CustomerNumber}", customer.CustomerNumber);
return true;
}
public async Task<string> GenerateCustomerNumberAsync()
{
var year = DateTime.UtcNow.Year;
var prefix = $"K-{year}-";
var lastNumber = await _context.Customers
.Where(c => c.CustomerNumber.StartsWith(prefix))
.OrderByDescending(c => c.CustomerNumber)
.Select(c => c.CustomerNumber)
.FirstOrDefaultAsync();
var nextNumber = 1;
if (lastNumber != null)
{
var numPart = lastNumber.Replace(prefix, "");
if (int.TryParse(numPart, out var lastNum))
nextNumber = lastNum + 1;
}
return $"{prefix}{nextNumber:D4}";
}
public async Task<int> GetStoredTireStackCountAsync(Guid customerId)
{
return await _context.TireStacks
.CountAsync(ts => ts.IsActive && ts.CustomerId == customerId && ts.Status == TireStackStatus.Stored);
}
// Vehicle methods
public async Task<List<Vehicle>> GetVehiclesAsync(Guid customerId)
{
return await _context.Vehicles
.Where(v => v.CustomerId == customerId && v.IsActive)
.OrderByDescending(v => v.IsPrimary)
.ThenBy(v => v.CreatedAt)
.ToListAsync();
}
public async Task<Vehicle?> GetVehicleByIdAsync(Guid vehicleId)
{
return await _context.Vehicles
.Include(v => v.Customer)
.Include(v => v.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.FirstOrDefaultAsync(v => v.Id == vehicleId);
}
public async Task<Vehicle> AddVehicleAsync(Guid customerId, Vehicle vehicle)
{
vehicle.CustomerId = customerId;
vehicle.CreatedAt = DateTime.UtcNow;
// If this is the first vehicle, make it primary
var existingVehicles = await _context.Vehicles
.CountAsync(v => v.CustomerId == customerId && v.IsActive);
if (existingVehicles == 0)
vehicle.IsPrimary = true;
_context.Vehicles.Add(vehicle);
await _context.SaveChangesAsync();
_logger.LogInformation("Vehicle added for customer {CustomerId}: {Make}",
customerId, vehicle.Make);
return vehicle;
}
public async Task<Vehicle> UpdateVehicleAsync(Vehicle vehicle)
{
vehicle.UpdatedAt = DateTime.UtcNow;
_context.Vehicles.Update(vehicle);
await _context.SaveChangesAsync();
_logger.LogInformation("Vehicle updated: {VehicleId} - {Make}",
vehicle.Id, vehicle.Make);
return vehicle;
}
public async Task<bool> DeleteVehicleAsync(Guid vehicleId)
{
var vehicle = await _context.Vehicles.FindAsync(vehicleId);
if (vehicle == null)
return false;
// Soft delete
vehicle.IsActive = false;
vehicle.UpdatedAt = DateTime.UtcNow;
// If this was primary, assign primary to another vehicle
if (vehicle.IsPrimary)
{
vehicle.IsPrimary = false;
var nextVehicle = await _context.Vehicles
.Where(v => v.CustomerId == vehicle.CustomerId && v.IsActive && v.Id != vehicleId)
.FirstOrDefaultAsync();
if (nextVehicle != null)
nextVehicle.IsPrimary = true;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Vehicle deactivated: {VehicleId}", vehicleId);
return true;
}
public async Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId)
{
var vehicles = await _context.Vehicles
.Where(v => v.CustomerId == customerId && v.IsActive)
.ToListAsync();
foreach (var v in vehicles)
{
v.IsPrimary = v.Id == vehicleId;
v.UpdatedAt = DateTime.UtcNow;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Primary vehicle set for customer {CustomerId}: {VehicleId}",
customerId, vehicleId);
}
}
File diff suppressed because it is too large. Load diff
+254
View File
@@ -0,0 +1,254 @@
using Lagerbestand.Models;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace Lagerbestand.Services;
/// <summary>
/// File storage service for disk/NFS-based file operations
/// </summary>
public class FileStorageService : IFileStorageService
{
private readonly ISiteSettingsService _siteSettingsService;
private readonly ILogger<FileStorageService> _logger;
private const string SettingsKey = "FileStorage";
public FileStorageService(ISiteSettingsService siteSettingsService, ILogger<FileStorageService> logger)
{
_siteSettingsService = siteSettingsService;
_logger = logger;
}
public async Task<FileStorageSettings> GetSettingsAsync()
{
try
{
var json = await _siteSettingsService.GetValueAsync(SettingsKey);
if (string.IsNullOrEmpty(json))
{
return new FileStorageSettings();
}
return JsonSerializer.Deserialize<FileStorageSettings>(json) ?? new FileStorageSettings();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading file storage settings");
return new FileStorageSettings();
}
}
public async Task SaveSettingsAsync(FileStorageSettings settings)
{
var json = JsonSerializer.Serialize(settings);
await _siteSettingsService.SetValueAsync(SettingsKey, json);
_logger.LogInformation("File storage settings saved. Path: {BasePath}, Enabled: {IsEnabled}",
settings.BasePath, settings.IsEnabled);
}
public Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return Task.FromResult((false, (string?)"Der Pfad darf nicht leer sein."));
}
try
{
// Check if directory exists or can be created
if (!Directory.Exists(path))
{
try
{
Directory.CreateDirectory(path);
_logger.LogInformation("Created storage directory: {Path}", path);
}
catch (Exception ex)
{
return Task.FromResult((false, (string?)$"Verzeichnis kann nicht erstellt werden: {ex.Message}"));
}
}
// Test write access
var testFile = Path.Combine(path, $".write_test_{Guid.NewGuid():N}");
try
{
File.WriteAllText(testFile, "test");
File.Delete(testFile);
}
catch (Exception ex)
{
return Task.FromResult((false, (string?)$"Keine Schreibrechte: {ex.Message}"));
}
return Task.FromResult((true, (string?)null));
}
catch (Exception ex)
{
return Task.FromResult((false, (string?)$"Fehler: {ex.Message}"));
}
}
public async Task<string> SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments")
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
throw new InvalidOperationException("Dateispeicher ist nicht konfiguriert.");
}
// Create unique filename to avoid collisions
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var uniqueId = Guid.NewGuid().ToString("N")[..8];
var safeFileName = SanitizeFileName(fileName);
var extension = Path.GetExtension(safeFileName);
var nameWithoutExt = Path.GetFileNameWithoutExtension(safeFileName);
var finalFileName = $"{nameWithoutExt}_{timestamp}_{uniqueId}{extension}";
// Create subdirectory path with year/month structure
var datePath = DateTime.UtcNow.ToString("yyyy/MM");
var relativePath = Path.Combine(subDirectory, datePath, finalFileName);
var fullPath = Path.Combine(settings.BasePath, relativePath);
// Ensure directory exists
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllBytesAsync(fullPath, content);
_logger.LogInformation("File saved: {RelativePath} ({Size} bytes)", relativePath, content.Length);
return relativePath;
}
public async Task<byte[]?> ReadFileAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return null;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
if (fullPath == null || !File.Exists(fullPath))
{
_logger.LogWarning("File not found: {RelativePath}", relativePath);
return null;
}
return await File.ReadAllBytesAsync(fullPath);
}
public async Task<bool> DeleteFileAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return false;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
if (fullPath == null)
{
return false;
}
if (!File.Exists(fullPath))
{
return true; // Already deleted
}
try
{
File.Delete(fullPath);
_logger.LogInformation("File deleted: {RelativePath}", relativePath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file: {RelativePath}", relativePath);
return false;
}
}
public async Task<bool> FileExistsAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return false;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
return await Task.FromResult(fullPath != null && File.Exists(fullPath));
}
public async Task<long> GetFileSizeAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return 0;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
if (fullPath == null || !File.Exists(fullPath))
{
return 0;
}
var fileInfo = new FileInfo(fullPath);
return await Task.FromResult(fileInfo.Length);
}
/// <summary>
/// Resolves a relative path against the base path and blocks path traversal attempts.
/// Returns null if the resolved path escapes the base directory.
/// </summary>
private string? ResolveSafePath(string basePath, string relativePath)
{
try
{
var fullBasePath = Path.GetFullPath(basePath);
var fullPath = Path.GetFullPath(Path.Combine(fullBasePath, relativePath));
if (!fullPath.StartsWith(fullBasePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Path traversal attempt blocked: {RelativePath}", relativePath);
return null;
}
return fullPath;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Invalid path rejected: {RelativePath}", relativePath);
return null;
}
}
private static string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
// Limit length
if (sanitized.Length > 200)
{
var ext = Path.GetExtension(sanitized);
var name = Path.GetFileNameWithoutExtension(sanitized);
sanitized = name[..(200 - ext.Length)] + ext;
}
return sanitized;
}
}
@@ -0,0 +1,49 @@
using Lagerbestand.Models;
namespace Lagerbestand.Services;
/// <summary>
/// Service interface for file storage operations (disk/NFS based)
/// </summary>
public interface IFileStorageService
{
/// <summary>
/// Gets the current storage settings
/// </summary>
Task<FileStorageSettings> GetSettingsAsync();
/// <summary>
/// Updates the storage settings
/// </summary>
Task SaveSettingsAsync(FileStorageSettings settings);
/// <summary>
/// Validates if the storage path is accessible and writable
/// </summary>
Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path);
/// <summary>
/// Saves a file to storage and returns the relative path
/// </summary>
Task<string> SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments");
/// <summary>
/// Reads a file from storage
/// </summary>
Task<byte[]?> ReadFileAsync(string relativePath);
/// <summary>
/// Deletes a file from storage
/// </summary>
Task<bool> DeleteFileAsync(string relativePath);
/// <summary>
/// Checks if a file exists in storage
/// </summary>
Task<bool> FileExistsAsync(string relativePath);
/// <summary>
/// Gets the file size in bytes
/// </summary>
Task<long> GetFileSizeAsync(string relativePath);
}
+98
View File
@@ -0,0 +1,98 @@
using Lagerbestand.Models;
namespace Lagerbestand.Services;
public interface IKanbanService
{
// Columns
Task<List<KanbanColumn>> GetColumnsAsync();
Task<KanbanColumn?> GetColumnByIdAsync(Guid id);
Task<KanbanColumn> CreateColumnAsync(KanbanColumn column);
Task<KanbanColumn> UpdateColumnAsync(KanbanColumn column);
Task DeleteColumnAsync(Guid id);
Task ReorderColumnsAsync(List<Guid> columnIds);
// Labels
Task<List<KanbanLabel>> GetLabelsAsync();
Task<KanbanLabel?> GetLabelByIdAsync(Guid id);
Task<KanbanLabel> CreateLabelAsync(KanbanLabel label);
Task<KanbanLabel> UpdateLabelAsync(KanbanLabel label);
Task DeleteLabelAsync(Guid id);
// Tasks
Task<List<KanbanTask>> GetTasksAsync(bool includeArchived = false);
Task<List<KanbanTask>> GetTasksByColumnAsync(Guid columnId);
Task<KanbanTask?> GetTaskByIdAsync(Guid id);
Task<KanbanTask> CreateTaskAsync(KanbanTask task, Guid? userId = null);
Task<KanbanTask> CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null);
Task<KanbanTask> UpdateTaskAsync(KanbanTask task, Guid? userId = null);
Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null);
Task DeleteTaskAsync(Guid id);
Task ArchiveTaskAsync(Guid id);
Task UnarchiveTaskAsync(Guid id);
Task<List<KanbanTask>> GetRecentlyArchivedTasksAsync(int days = 7);
Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30);
// Task Todos
Task<KanbanTaskTodo> AddTodoAsync(Guid taskId, string text);
Task<KanbanTaskTodo> UpdateTodoAsync(KanbanTaskTodo todo);
Task ToggleTodoAsync(Guid todoId);
Task DeleteTodoAsync(Guid todoId);
Task ReorderTodosAsync(Guid taskId, List<Guid> todoIds);
// Task Comments/Chat
Task<List<KanbanTaskComment>> GetCommentsAsync(Guid taskId);
Task<KanbanTaskComment> AddCommentAsync(Guid taskId, string content, Guid? userId);
Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null);
// Task Notes
Task<List<KanbanTaskNote>> GetNotesAsync(Guid taskId);
Task<KanbanTaskNote> AddNoteAsync(Guid taskId, string title, string content);
Task<KanbanTaskNote> UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null);
Task DeleteNoteAsync(Guid noteId);
// Task Attachments
Task<List<KanbanTaskAttachment>> GetAttachmentsAsync(Guid taskId);
Task<KanbanTaskAttachment?> GetAttachmentByIdAsync(Guid id);
Task<KanbanTaskAttachment> AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId);
Task<byte[]?> GetAttachmentContentAsync(Guid attachmentId);
Task DeleteAttachmentAsync(Guid attachmentId);
// Task Labels
Task AddLabelToTaskAsync(Guid taskId, Guid labelId);
Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId);
Task SetTaskLabelsAsync(Guid taskId, List<Guid> labelIds);
// Task Assignees
Task AddAssigneeAsync(Guid taskId, Guid userId);
Task RemoveAssigneeAsync(Guid taskId, Guid userId);
Task SetAssigneesAsync(Guid taskId, List<Guid> userIds, Guid? changedByUserId = null);
// Task Customers
Task AddCustomerToTaskAsync(Guid taskId, Guid customerId);
Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId);
Task SetTaskCustomersAsync(Guid taskId, Dictionary<Guid, Guid?> customerVehicleMap);
// Task TireStacks
Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId);
Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId);
Task SetTaskTireStacksAsync(Guid taskId, List<Guid> tireStackIds);
// Templates
Task<List<KanbanTaskTemplate>> GetTemplatesAsync();
Task<KanbanTaskTemplate?> GetTemplateByIdAsync(Guid id);
Task<KanbanTaskTemplate> CreateTemplateAsync(KanbanTaskTemplate template);
Task<KanbanTaskTemplate> UpdateTemplateAsync(KanbanTaskTemplate template);
Task DeleteTemplateAsync(Guid id);
// Template Todos
Task<KanbanTaskTemplateTodo> AddTemplateTodoAsync(Guid templateId, string text);
Task DeleteTemplateTodoAsync(Guid todoId);
Task SyncTemplateTodosAsync(Guid templateId, List<KanbanTaskTemplateTodo> todos);
// Template Labels
Task SetTemplateLabelsAsync(Guid templateId, List<Guid> labelIds);
// Statistics/Dashboard
Task<KanbanStatistics> GetStatisticsAsync();
}
+189
View File
@@ -0,0 +1,189 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface IInventoryService
{
Task<List<InventoryItem>> GetAllAsync();
Task<InventoryItem?> GetByIdAsync(Guid id);
Task<InventoryItem?> GetByQrCodeAsync(string qrCode);
Task<bool> ExistsAsync(string qrCode);
Task<InventoryItem> AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null);
Task<List<InventoryItem>> AddItemsAsync(List<string> qrCodes, Guid? catalogItemId = null);
Task<bool> RemoveItemAsync(string qrCode);
Task<int> RemoveItemsAsync(List<string> qrCodes);
Task<InventoryItem> UpdateCategoryAsync(Guid id, Guid? catalogItemId);
Task<int> UpdateCategoryForItemsAsync(List<Guid> itemIds, Guid? catalogItemId);
Task DeleteAsync(Guid id);
}
public class InventoryService : IInventoryService
{
private readonly AppDbContext _context;
private readonly ILogger<InventoryService> _logger;
public InventoryService(AppDbContext context, ILogger<InventoryService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<InventoryItem>> GetAllAsync()
{
return await _context.InventoryItems
.AsNoTracking()
.Include(x => x.CatalogItem)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
}
public async Task<InventoryItem?> GetByIdAsync(Guid id)
{
return await _context.InventoryItems
.Include(x => x.CatalogItem)
.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<InventoryItem?> GetByQrCodeAsync(string qrCode)
{
return await _context.InventoryItems
.Include(x => x.CatalogItem)
.FirstOrDefaultAsync(x => x.QrCode == qrCode);
}
public async Task<bool> ExistsAsync(string qrCode)
{
return await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode);
}
public async Task<InventoryItem> AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null)
{
if (await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode))
throw new InvalidOperationException($"Artikel mit QR-Code '{qrCode}' ist bereits im Bestand.");
var item = new InventoryItem
{
Id = Guid.NewGuid(),
QrCode = qrCode,
CatalogItemId = catalogItemId,
Name = name,
CreatedAt = DateTime.UtcNow
};
_context.InventoryItems.Add(item);
await _context.SaveChangesAsync();
return await GetByIdAsync(item.Id) ?? item;
}
public async Task<List<InventoryItem>> AddItemsAsync(List<string> qrCodes, Guid? catalogItemId = null)
{
var addedItems = new List<InventoryItem>();
var now = DateTime.UtcNow;
var distinctCodes = qrCodes.Distinct().ToList();
var existingCodes = await _context.InventoryItems
.Where(x => distinctCodes.Contains(x.QrCode))
.Select(x => x.QrCode)
.ToListAsync();
var existingSet = existingCodes.ToHashSet();
foreach (var qrCode in distinctCodes)
{
if (existingSet.Contains(qrCode))
continue;
var item = new InventoryItem
{
Id = Guid.NewGuid(),
QrCode = qrCode,
CatalogItemId = catalogItemId,
CreatedAt = now
};
_context.InventoryItems.Add(item);
addedItems.Add(item);
}
if (addedItems.Count > 0)
await _context.SaveChangesAsync();
return addedItems;
}
public async Task<bool> RemoveItemAsync(string qrCode)
{
var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode);
if (item == null)
return false;
_context.InventoryItems.Remove(item);
await _context.SaveChangesAsync();
return true;
}
public async Task<int> RemoveItemsAsync(List<string> qrCodes)
{
var count = 0;
foreach (var qrCode in qrCodes.Distinct())
{
var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode);
if (item != null)
{
_context.InventoryItems.Remove(item);
count++;
}
}
if (count > 0)
await _context.SaveChangesAsync();
return count;
}
public async Task<InventoryItem> UpdateCategoryAsync(Guid id, Guid? catalogItemId)
{
var item = await _context.InventoryItems.FindAsync(id)
?? throw new InvalidOperationException("Artikel nicht gefunden");
item.CatalogItemId = catalogItemId;
item.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return item;
}
public async Task<int> UpdateCategoryForItemsAsync(List<Guid> itemIds, Guid? catalogItemId)
{
var now = DateTime.UtcNow;
var count = 0;
foreach (var id in itemIds)
{
var item = await _context.InventoryItems.FindAsync(id);
if (item != null)
{
item.CatalogItemId = catalogItemId;
item.UpdatedAt = now;
count++;
}
}
if (count > 0)
await _context.SaveChangesAsync();
return count;
}
public async Task DeleteAsync(Guid id)
{
var item = await _context.InventoryItems.FindAsync(id);
if (item != null)
{
_context.InventoryItems.Remove(item);
await _context.SaveChangesAsync();
}
}
}
File diff suppressed because it is too large. Load diff
+515
View File
@@ -0,0 +1,515 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Result of a Lexware customer sync operation
/// </summary>
public class LexwareSyncResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int CustomersCreated { get; set; }
public int CustomersUpdated { get; set; }
public int CustomersDeactivated { get; set; }
public int TotalProcessed { get; set; }
}
/// <summary>
/// Lexware API contact response models
/// </summary>
public class LexwareContactsResponse
{
[JsonPropertyName("content")]
public List<LexwareContact> Content { get; set; } = new();
[JsonPropertyName("totalPages")]
public int TotalPages { get; set; }
[JsonPropertyName("totalElements")]
public int TotalElements { get; set; }
[JsonPropertyName("last")]
public bool Last { get; set; }
[JsonPropertyName("number")]
public int Number { get; set; }
}
public class LexwareContact
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("organizationId")]
public string? OrganizationId { get; set; }
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("roles")]
public LexwareRoles? Roles { get; set; }
[JsonPropertyName("company")]
public LexwareCompany? Company { get; set; }
[JsonPropertyName("person")]
public LexwarePerson? Person { get; set; }
[JsonPropertyName("addresses")]
public LexwareAddresses? Addresses { get; set; }
[JsonPropertyName("emailAddresses")]
public LexwareEmailAddresses? EmailAddresses { get; set; }
[JsonPropertyName("phoneNumbers")]
public LexwarePhoneNumbers? PhoneNumbers { get; set; }
[JsonPropertyName("note")]
public string? Note { get; set; }
[JsonPropertyName("archived")]
public bool Archived { get; set; }
}
public class LexwareRoles
{
[JsonPropertyName("customer")]
public LexwareCustomerRole? Customer { get; set; }
}
public class LexwareCustomerRole
{
[JsonPropertyName("number")]
public long? Number { get; set; }
}
public class LexwareCompany
{
[JsonPropertyName("name")]
public string? Name { get; set; }
}
public class LexwarePerson
{
[JsonPropertyName("salutation")]
public string? Salutation { get; set; }
[JsonPropertyName("firstName")]
public string? FirstName { get; set; }
[JsonPropertyName("lastName")]
public string? LastName { get; set; }
}
public class LexwareAddresses
{
[JsonPropertyName("billing")]
public List<LexwareAddress>? Billing { get; set; }
}
public class LexwareAddress
{
[JsonPropertyName("street")]
public string? Street { get; set; }
[JsonPropertyName("zip")]
public string? Zip { get; set; }
[JsonPropertyName("city")]
public string? City { get; set; }
[JsonPropertyName("countryCode")]
public string? CountryCode { get; set; }
}
public class LexwareEmailAddresses
{
[JsonPropertyName("business")]
public List<string>? Business { get; set; }
[JsonPropertyName("private")]
public List<string>? Private { get; set; }
[JsonPropertyName("other")]
public List<string>? Other { get; set; }
}
public class LexwarePhoneNumbers
{
[JsonPropertyName("business")]
public List<string>? Business { get; set; }
[JsonPropertyName("private")]
public List<string>? Private { get; set; }
[JsonPropertyName("mobile")]
public List<string>? Mobile { get; set; }
}
public interface ILexwareSyncService
{
/// <summary>
/// Tests the API connection with the given settings
/// </summary>
Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings);
/// <summary>
/// Syncs customers from Lexware API to the local database
/// </summary>
Task<LexwareSyncResult> SyncCustomersAsync(CancellationToken cancellationToken = default);
}
public class LexwareSyncService : ILexwareSyncService
{
private readonly AppDbContext _context;
private readonly ISiteSettingsService _siteSettingsService;
private readonly ICustomerService _customerService;
private readonly ILogger<LexwareSyncService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
public LexwareSyncService(
AppDbContext context,
ISiteSettingsService siteSettingsService,
ICustomerService customerService,
ILogger<LexwareSyncService> logger,
IHttpClientFactory httpClientFactory)
{
_context = context;
_siteSettingsService = siteSettingsService;
_customerService = customerService;
_logger = logger;
_httpClientFactory = httpClientFactory;
}
public async Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings)
{
if (!settings.IsConfigured)
return (false, "API URL und API-Key müssen konfiguriert sein.");
try
{
using var client = CreateHttpClient(settings);
var response = await client.GetAsync("contacts?page=0&size=1");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<LexwareContactsResponse>(json);
return (true, $"Verbindung erfolgreich! {result?.TotalElements ?? 0} Kontakte gefunden.");
}
return response.StatusCode switch
{
System.Net.HttpStatusCode.Unauthorized => (false, "Authentifizierung fehlgeschlagen. Bitte API-Key prüfen."),
System.Net.HttpStatusCode.Forbidden => (false, "Zugriff verweigert. API-Key hat keine ausreichenden Berechtigungen."),
System.Net.HttpStatusCode.NotFound => (false, "API-Endpunkt nicht gefunden. Bitte URL prüfen."),
_ => (false, $"API-Fehler: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}")
};
}
catch (HttpRequestException ex)
{
return (false, $"Verbindungsfehler: {ex.Message}");
}
catch (TaskCanceledException)
{
return (false, "Zeitüberschreitung bei der Verbindung.");
}
catch (Exception ex)
{
return (false, $"Fehler: {ex.Message}");
}
}
public async Task<LexwareSyncResult> SyncCustomersAsync(CancellationToken cancellationToken = default)
{
var settings = await _siteSettingsService.GetLexwareApiSettingsAsync();
if (!settings.IsEnabled || !settings.IsConfigured)
{
return new LexwareSyncResult
{
Success = false,
Message = "Lexware API ist nicht konfiguriert oder deaktiviert."
};
}
var result = new LexwareSyncResult();
try
{
_logger.LogInformation("Starting Lexware customer sync...");
using var client = CreateHttpClient(settings);
var allContacts = new List<LexwareContact>();
// Fetch all pages of customers
var page = 0;
bool lastPage = false;
while (!lastPage)
{
cancellationToken.ThrowIfCancellationRequested();
var response = await client.GetAsync($"contacts?page={page}&size=250&customer=true", cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var pageResult = JsonSerializer.Deserialize<LexwareContactsResponse>(json);
if (pageResult?.Content != null)
{
// Only include contacts with customer role that are not archived
allContacts.AddRange(pageResult.Content.Where(c => !c.Archived));
}
lastPage = pageResult?.Last ?? true;
page++;
}
_logger.LogInformation("Fetched {Count} customer contacts from Lexware", allContacts.Count);
// Get all existing customers indexed by CustomerNumber (include inactive for reactivation)
var existingCustomers = await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.ToListAsync(cancellationToken);
var customersByNumber = existingCustomers
.Where(c => !string.IsNullOrEmpty(c.CustomerNumber))
.ToDictionary(c => c.CustomerNumber, c => c);
foreach (var contact in allContacts)
{
result.TotalProcessed++;
var customerNumber = contact.Roles?.Customer?.Number?.ToString() ?? "";
if (string.IsNullOrEmpty(customerNumber))
continue;
var firstName = contact.Person?.FirstName?.Trim() ?? "";
var lastName = contact.Person?.LastName?.Trim() ?? "";
// Skip contacts without a name
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
{
// Use company name as last name fallback
if (!string.IsNullOrEmpty(contact.Company?.Name))
{
lastName = contact.Company.Name;
}
else
{
continue;
}
}
var company = contact.Company?.Name?.Trim();
var phone = GetFirstPhone(contact.PhoneNumbers);
var email = GetFirstEmail(contact.EmailAddresses);
var notes = contact.Note?.Trim();
if (customersByNumber.TryGetValue(customerNumber, out var existing))
{
// Update existing customer
bool changed = false;
if (existing.FirstName != firstName) { existing.FirstName = firstName; changed = true; }
if (existing.LastName != lastName) { existing.LastName = lastName; changed = true; }
if (existing.Company != company) { existing.Company = company; changed = true; }
if (existing.Phone != phone) { existing.Phone = phone; changed = true; }
if (existing.Email != email) { existing.Email = email; changed = true; }
if (existing.Notes != notes) { existing.Notes = notes; changed = true; }
// Reactivate if previously deactivated
if (!existing.IsActive) { existing.IsActive = true; changed = true; }
if (changed)
{
existing.UpdatedAt = DateTime.UtcNow;
_context.Customers.Update(existing);
result.CustomersUpdated++;
}
}
else
{
// Create new customer
var newCustomer = new Customer
{
CustomerNumber = customerNumber,
FirstName = string.IsNullOrEmpty(firstName) ? "-" : firstName,
LastName = string.IsNullOrEmpty(lastName) ? "-" : lastName,
Company = company,
Phone = phone,
Email = email,
Notes = notes,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_context.Customers.Add(newCustomer);
result.CustomersCreated++;
}
}
await _context.SaveChangesAsync(cancellationToken);
// Deactivate customers not found in Lexware (Lexware is single source of truth)
var lexwareCustomerNumbers = allContacts
.Select(c => c.Roles?.Customer?.Number?.ToString())
.Where(n => !string.IsNullOrEmpty(n))
.ToHashSet();
var customersToDeactivate = await _context.Customers
.Where(c => c.IsActive && !string.IsNullOrEmpty(c.CustomerNumber) && !lexwareCustomerNumbers.Contains(c.CustomerNumber))
.ToListAsync(cancellationToken);
foreach (var customer in customersToDeactivate)
{
customer.IsActive = false;
customer.UpdatedAt = DateTime.UtcNow;
result.CustomersDeactivated++;
_logger.LogWarning("Customer {CustomerNumber} ({Name}) deactivated - not found in Lexware",
customer.CustomerNumber, customer.FullName);
}
if (customersToDeactivate.Count > 0)
await _context.SaveChangesAsync(cancellationToken);
// Update sync timestamp
settings.LastSyncAt = DateTime.UtcNow;
settings.LastSyncCount = result.TotalProcessed;
settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert";
await _siteSettingsService.SaveLexwareApiSettingsAsync(settings);
result.Success = true;
result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert ({result.TotalProcessed} verarbeitet)";
_logger.LogInformation("Lexware sync completed: {Message}", result.Message);
}
catch (HttpRequestException ex)
{
result.Success = false;
result.Message = $"API-Verbindungsfehler: {ex.Message}";
_logger.LogError(ex, "Lexware sync failed: HTTP error");
}
catch (Exception ex)
{
result.Success = false;
result.Message = $"Synchronisierungsfehler: {ex.Message}";
_logger.LogError(ex, "Lexware sync failed");
}
return result;
}
private HttpClient CreateHttpClient(LexwareApiSettings settings)
{
var client = _httpClientFactory.CreateClient("LexwareApi");
client.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
private static string? GetFirstPhone(LexwarePhoneNumbers? phones)
{
if (phones == null) return null;
return phones.Mobile?.FirstOrDefault()
?? phones.Business?.FirstOrDefault()
?? phones.Private?.FirstOrDefault();
}
private static string? GetFirstEmail(LexwareEmailAddresses? emails)
{
if (emails == null) return null;
return emails.Business?.FirstOrDefault()
?? emails.Private?.FirstOrDefault()
?? emails.Other?.FirstOrDefault();
}
}
/// <summary>
/// Background service that runs daily Lexware customer sync
/// </summary>
public class LexwareSyncBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<LexwareSyncBackgroundService> _logger;
public LexwareSyncBackgroundService(IServiceProvider serviceProvider, ILogger<LexwareSyncBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Lexware sync background service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISiteSettingsService>();
var settings = await settingsService.GetLexwareApiSettingsAsync();
if (settings is { IsEnabled: true, IsConfigured: true, AutoSyncEnabled: true })
{
// Parse target time
if (TimeSpan.TryParse(settings.AutoSyncTime, out var targetTime))
{
var now = DateTime.UtcNow;
var nextRun = now.Date.Add(targetTime);
// If we've already passed the target time today, schedule for tomorrow
if (now > nextRun)
nextRun = nextRun.AddDays(1);
var delay = nextRun - now;
_logger.LogDebug("Lexware auto-sync scheduled for {NextRun} (in {Delay})", nextRun, delay);
await Task.Delay(delay, stoppingToken);
if (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Running scheduled Lexware customer sync...");
using var syncScope = _serviceProvider.CreateScope();
var syncService = syncScope.ServiceProvider.GetRequiredService<ILexwareSyncService>();
var result = await syncService.SyncCustomersAsync(stoppingToken);
_logger.LogInformation("Scheduled Lexware sync result: {Message}", result.Message);
}
}
else
{
// Invalid time format, wait and retry
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
else
{
// Not configured or disabled, check again in 15 minutes
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Lexware sync background service");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
_logger.LogInformation("Lexware sync background service stopped");
}
}
@@ -0,0 +1,275 @@
using System.Text.RegularExpressions;
using Lagerbestand.Data;
using Lagerbestand.Exceptions;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Result of location code validation.
/// </summary>
public record LocationCodeValidationResult(
bool IsValid,
string? ErrorMessage = null,
AreaType? Area = null,
int? Row = null,
ShelfPosition? Position = null,
int? SlotNumber = null
);
/// <summary>
/// Parsed components of a location code.
/// </summary>
public record ParsedLocationCode(AreaType Area, int Row, ShelfPosition Position, int SlotNumber)
{
/// <summary>
/// Generates the 4-digit code string.
/// </summary>
public string ToCode() => $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
}
/// <summary>
/// Service for generating, parsing, and validating storage location codes.
/// Location codes follow the ABCD format where:
/// - A: Gebäude (1=Werkstatt, 2=Lager)
/// - B: Reihe (Werkstatt: 1-3, Lager: 1-5)
/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten)
/// - D: Platz (1-9)
/// </summary>
public interface ILocationCodeService
{
/// <summary>
/// Generates a location code from component parts.
/// </summary>
/// <param name="area">Storage building</param>
/// <param name="row">Row number (Werkstatt: 1-3, Lager: 1-5)</param>
/// <param name="position">Shelf position (side/level)</param>
/// <param name="slot">Slot number (1-9)</param>
/// <returns>4-digit location code string</returns>
string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot);
/// <summary>
/// Parses a location code string into its component parts.
/// </summary>
/// <param name="code">4-digit location code</param>
/// <returns>Parsed location code components</returns>
/// <exception cref="InvalidLocationCodeException">If code format is invalid</exception>
ParsedLocationCode ParseLocationCode(string code);
/// <summary>
/// Tries to parse a location code without throwing exceptions.
/// </summary>
bool TryParseLocationCode(string code, out ParsedLocationCode? result);
/// <summary>
/// Validates a location code format.
/// </summary>
/// <param name="code">Code to validate</param>
/// <returns>Validation result with details</returns>
LocationCodeValidationResult ValidateLocationCode(string code);
/// <summary>
/// Finds the next available (existing, unoccupied) location in a given area and row.
/// </summary>
/// <param name="area">Storage building</param>
/// <param name="row">Row number</param>
/// <returns>Parsed code of next free location, or null if row is full</returns>
Task<ParsedLocationCode?> GetNextAvailableSlotAsync(AreaType area, int row);
/// <summary>
/// Checks if a specific location code is available (exists and not occupied).
/// </summary>
Task<bool> IsLocationAvailableAsync(string code);
/// <summary>
/// Checks if a location code already exists in the database.
/// </summary>
Task<bool> CodeExistsAsync(string code);
/// <summary>
/// Gets all occupied slots in a specific area and row.
/// </summary>
Task<List<int>> GetOccupiedSlotsAsync(AreaType area, int row);
/// <summary>
/// Gets all existing slot numbers in a specific area and row.
/// </summary>
Task<List<int>> GetExistingSlotsAsync(AreaType area, int row);
/// <summary>
/// Formats a location code for display with human-readable text.
/// </summary>
string FormatForDisplay(string code);
}
/// <inheritdoc />
public class LocationCodeService : ILocationCodeService
{
private readonly AppDbContext _context;
private readonly ILogger<LocationCodeService> _logger;
private static readonly Regex CodeRegex = new(StorageLocation.LocationCodePattern, RegexOptions.Compiled);
public LocationCodeService(AppDbContext context, ILogger<LocationCodeService> logger)
{
_context = context;
_logger = logger;
}
/// <inheritdoc />
public string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot)
{
var maxRow = area.GetMaxRow();
if (row < StorageLocation.MinRow || row > maxRow)
throw new ArgumentOutOfRangeException(nameof(row),
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
if (slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot)
throw new ArgumentOutOfRangeException(nameof(slot),
$"Platz muss zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
return $"{(int)area}{row}{(int)position}{slot}";
}
/// <inheritdoc />
public ParsedLocationCode ParseLocationCode(string code)
{
var validation = ValidateLocationCode(code);
if (!validation.IsValid)
throw new InvalidLocationCodeException(code, validation.ErrorMessage ?? "Ungültiges Format");
return new ParsedLocationCode(
validation.Area!.Value,
validation.Row!.Value,
validation.Position!.Value,
validation.SlotNumber!.Value
);
}
/// <inheritdoc />
public bool TryParseLocationCode(string code, out ParsedLocationCode? result)
{
result = null;
var validation = ValidateLocationCode(code);
if (!validation.IsValid)
return false;
result = new ParsedLocationCode(
validation.Area!.Value,
validation.Row!.Value,
validation.Position!.Value,
validation.SlotNumber!.Value
);
return true;
}
/// <inheritdoc />
public LocationCodeValidationResult ValidateLocationCode(string code)
{
if (string.IsNullOrWhiteSpace(code))
return new LocationCodeValidationResult(false, "Lagerplatz-Code darf nicht leer sein.");
code = code.Trim();
if (code.Length != 4)
return new LocationCodeValidationResult(false,
$"Lagerplatz-Code muss genau 4 Zeichen lang sein (aktuell: {code.Length}).");
if (!CodeRegex.IsMatch(code))
return new LocationCodeValidationResult(false,
"Ungültiges Format. Erwartet: ABCD wobei A=Gebäude (1-2), B=Reihe, C=Seite/Ebene (1-4), D=Platz (1-9).");
// Parse area (position A)
if (!AreaTypeExtensions.TryFromCode(code[0], out var area))
return new LocationCodeValidationResult(false,
$"Ungültiges Gebäude '{code[0]}'. Erlaubt: 1 (Werkstatt), 2 (Lager).");
// Parse row (position B) - max row depends on building
if (!int.TryParse(code[1].ToString(), out var row))
return new LocationCodeValidationResult(false,
$"Ungültige Reihe '{code[1]}'.");
var maxRow = area.GetMaxRow();
if (row < StorageLocation.MinRow || row > maxRow)
return new LocationCodeValidationResult(false,
$"Reihe {row} außerhalb des gültigen Bereichs für {area.GetDisplayName()} ({StorageLocation.MinRow}-{maxRow}).");
// Parse position (position C)
if (!ShelfPositionExtensions.TryFromCode(code[2], out var position))
return new LocationCodeValidationResult(false,
$"Ungültige Seite/Ebene '{code[2]}'. Erlaubt: 1 (links oben), 2 (links unten), 3 (rechts oben), 4 (rechts unten).");
// Parse slot (position D)
if (!int.TryParse(code[3].ToString(), out var slot) || slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot)
return new LocationCodeValidationResult(false,
$"Ungültiger Platz '{code[3]}'. Erlaubt: {StorageLocation.MinSlot}-{StorageLocation.MaxSlot}.");
return new LocationCodeValidationResult(true, null, area, row, position, slot);
}
/// <inheritdoc />
public async Task<ParsedLocationCode?> GetNextAvailableSlotAsync(AreaType area, int row)
{
var freeLocation = await _context.StorageLocations
.AsNoTracking()
.Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsAvailable && !l.IsOccupied)
.OrderBy(l => l.SlotNumber)
.ThenBy(l => l.Position)
.FirstOrDefaultAsync();
if (freeLocation == null)
return null; // Row is full
return new ParsedLocationCode(freeLocation.Area, freeLocation.Row, freeLocation.Position, freeLocation.SlotNumber);
}
/// <inheritdoc />
public async Task<bool> IsLocationAvailableAsync(string code)
{
var validation = ValidateLocationCode(code);
if (!validation.IsValid)
return false;
var location = await _context.StorageLocations
.FirstOrDefaultAsync(l => l.Code == code && l.IsActive);
return location != null && !location.IsOccupied && location.IsAvailable;
}
/// <inheritdoc />
public async Task<bool> CodeExistsAsync(string code)
{
return await _context.StorageLocations
.AnyAsync(l => l.Code == code);
}
/// <inheritdoc />
public async Task<List<int>> GetOccupiedSlotsAsync(AreaType area, int row)
{
return await _context.StorageLocations
.Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsOccupied)
.Select(l => l.SlotNumber)
.OrderBy(s => s)
.ToListAsync();
}
/// <inheritdoc />
public async Task<List<int>> GetExistingSlotsAsync(AreaType area, int row)
{
return await _context.StorageLocations
.Where(l => l.Area == area && l.Row == row && l.IsActive)
.Select(l => l.SlotNumber)
.OrderBy(s => s)
.ToListAsync();
}
/// <inheritdoc />
public string FormatForDisplay(string code)
{
if (!TryParseLocationCode(code, out var parsed) || parsed == null)
return code;
return $"{parsed.Area.GetDisplayName()}, Reihe {parsed.Row}, {parsed.Position.GetDisplayName()}, Platz {parsed.SlotNumber}";
}
}
@@ -0,0 +1,452 @@
using System.Text.Json;
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface ISiteSettingsService
{
Task<PromoBannerSettings> GetPromoBannerSettingsAsync();
Task SavePromoBannerSettingsAsync(PromoBannerSettings settings);
Task<LexwareApiSettings> GetLexwareApiSettingsAsync();
Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings);
Task<TwoFactorSettings> GetTwoFactorSettingsAsync();
Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings);
Task<SmtpSettings> GetSmtpSettingsAsync();
Task SaveSmtpSettingsAsync(SmtpSettings settings);
Task<SessionSettings> GetSessionSettingsAsync();
Task SaveSessionSettingsAsync(SessionSettings settings);
/// <summary>
/// Gets a raw setting value by key
/// </summary>
Task<string?> GetValueAsync(string key);
/// <summary>
/// Sets a raw setting value by key
/// </summary>
Task SetValueAsync(string key, string value);
}
public class SiteSettingsService : ISiteSettingsService
{
private const string PromoBannerKey = "PromoBanner";
private const string LexwareApiKey = "LexwareApi";
private const string TwoFactorKey = "TwoFactor";
private const string SmtpKey = "Smtp";
private const string SessionKey = "Session";
private readonly AppDbContext _context;
private readonly ILogger<SiteSettingsService> _logger;
public SiteSettingsService(AppDbContext context, ILogger<SiteSettingsService> logger)
{
_context = context;
_logger = logger;
}
public async Task<PromoBannerSettings> GetPromoBannerSettingsAsync()
{
try
{
// Quick timeout to prevent blocking app startup
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == PromoBannerKey, cts.Token);
if (setting == null)
{
return new PromoBannerSettings();
}
var settings = JsonSerializer.Deserialize<PromoBannerSettings>(setting.Value);
return settings ?? new PromoBannerSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Banner settings query timed out, returning default");
return new PromoBannerSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
// Table doesn't exist yet - this is normal during first startup
_logger.LogDebug("SiteSettings table not found, returning default settings");
return new PromoBannerSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load promo banner settings");
return new PromoBannerSettings();
}
}
public async Task SavePromoBannerSettingsAsync(PromoBannerSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == PromoBannerKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
var newSetting = new SiteSettings
{
Key = PromoBannerKey,
Value = json,
CreatedAt = DateTime.UtcNow
};
_context.SiteSettings.Add(newSetting);
}
await _context.SaveChangesAsync();
_logger.LogInformation("Promo banner settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save promo banner settings");
throw;
}
}
public async Task<LexwareApiSettings> GetLexwareApiSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == LexwareApiKey, cts.Token);
if (setting == null)
return new LexwareApiSettings();
var settings = JsonSerializer.Deserialize<LexwareApiSettings>(setting.Value);
return settings ?? new LexwareApiSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Lexware settings query timed out, returning default");
return new LexwareApiSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
_logger.LogDebug("SiteSettings table not found, returning default Lexware settings");
return new LexwareApiSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Lexware API settings");
return new LexwareApiSettings();
}
}
public async Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == LexwareApiKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
var newSetting = new SiteSettings
{
Key = LexwareApiKey,
Value = json,
CreatedAt = DateTime.UtcNow
};
_context.SiteSettings.Add(newSetting);
}
await _context.SaveChangesAsync();
_logger.LogInformation("Lexware API settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Lexware API settings");
throw;
}
}
public async Task<TwoFactorSettings> GetTwoFactorSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == TwoFactorKey, cts.Token);
if (setting == null)
return new TwoFactorSettings();
return JsonSerializer.Deserialize<TwoFactorSettings>(setting.Value) ?? new TwoFactorSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("TwoFactor settings query timed out, returning default");
return new TwoFactorSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
return new TwoFactorSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load TwoFactor settings");
return new TwoFactorSettings();
}
}
public async Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == TwoFactorKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
_context.SiteSettings.Add(new SiteSettings
{
Key = TwoFactorKey,
Value = json,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
_logger.LogInformation("TwoFactor settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save TwoFactor settings");
throw;
}
}
public async Task<SmtpSettings> GetSmtpSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == SmtpKey, cts.Token);
if (setting == null)
return new SmtpSettings();
return JsonSerializer.Deserialize<SmtpSettings>(setting.Value) ?? new SmtpSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("SMTP settings query timed out, returning default");
return new SmtpSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
return new SmtpSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load SMTP settings");
return new SmtpSettings();
}
}
public async Task SaveSmtpSettingsAsync(SmtpSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == SmtpKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
_context.SiteSettings.Add(new SiteSettings
{
Key = SmtpKey,
Value = json,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
_logger.LogInformation("SMTP settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save SMTP settings");
throw;
}
}
public async Task<SessionSettings> GetSessionSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == SessionKey, cts.Token);
if (setting == null)
return new SessionSettings();
return JsonSerializer.Deserialize<SessionSettings>(setting.Value) ?? new SessionSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Session settings query timed out, returning default");
return new SessionSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
return new SessionSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Session settings");
return new SessionSettings();
}
}
public async Task SaveSessionSettingsAsync(SessionSettings settings)
{
try
{
// Clamp to safe ranges before persisting
settings.IdleTimeoutMinutes = settings.GetSafeIdleTimeoutMinutes();
settings.CircuitRetentionHours = settings.GetSafeCircuitRetentionHours();
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == SessionKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
_context.SiteSettings.Add(new SiteSettings
{
Key = SessionKey,
Value = json,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
_logger.LogInformation("Session settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Session settings");
throw;
}
}
public async Task<string?> GetValueAsync(string key)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == key, cts.Token);
return setting?.Value;
}
catch (OperationCanceledException)
{
_logger.LogDebug("Settings query for '{Key}' timed out", key);
return null;
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
_logger.LogDebug("SiteSettings table not found for key '{Key}'", key);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load setting '{Key}'", key);
return null;
}
}
public async Task SetValueAsync(string key, string value)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == key);
if (existing != null)
{
existing.Value = value;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
var newSetting = new SiteSettings
{
Key = key,
Value = value,
CreatedAt = DateTime.UtcNow
};
_context.SiteSettings.Add(newSetting);
}
await _context.SaveChangesAsync();
_logger.LogInformation("Setting '{Key}' saved successfully", key);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save setting '{Key}'", key);
throw;
}
}
}
@@ -0,0 +1,447 @@
using Lagerbestand.Data;
using Lagerbestand.Exceptions;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Statistics about storage location occupancy.
/// </summary>
public record OccupancyStatistics(
int TotalLocations,
int OccupiedLocations,
int AvailableLocations,
int BlockedLocations,
int TotalByLager,
int OccupiedByLager,
int TotalByWerkstatt,
int OccupiedByWerkstatt,
Dictionary<int, int> LocationsByRow,
Dictionary<int, int> OccupiedByRow
);
public interface IStorageLocationService
{
Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false);
Task<StorageLocation?> GetByIdAsync(Guid id);
Task<StorageLocation?> GetByCodeAsync(string code);
Task<List<StorageLocation>> GetAvailableAsync();
Task<List<StorageLocation>> GetByAreaAsync(AreaType area);
Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row);
Task<StorageLocation> CreateAsync(StorageLocation location);
Task<StorageLocation> UpdateAsync(StorageLocation location);
Task<bool> DeleteAsync(Guid id);
Task<List<AreaType>> GetAreasAsync();
Task<List<int>> GetRowsAsync(AreaType area);
Task<bool> IsLocationAvailableAsync(Guid locationId);
Task<int> GetOccupancyAsync(Guid locationId);
Task AssignTireStackAsync(string locationCode, Guid tireStackId);
Task ClearLocationAsync(string locationCode);
Task MoveStackAsync(string fromCode, string toCode);
Task<OccupancyStatistics> GetOccupancyStatisticsAsync();
Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot);
/// <summary>
/// Ensures all default storage locations exist (Werkstatt: 3 rows, Lager: 5 rows,
/// each row 9 slots × 4 positions = 288 total). Creates missing, returns number created.
/// </summary>
Task<int> SeedDefaultLocationsAsync();
}
public class StorageLocationService : IStorageLocationService
{
private readonly AppDbContext _context;
private readonly ILocationCodeService _locationCodeService;
private readonly ILogger<StorageLocationService> _logger;
public StorageLocationService(
AppDbContext context,
ILocationCodeService locationCodeService,
ILogger<StorageLocationService> logger)
{
_context = context;
_locationCodeService = locationCodeService;
_logger = logger;
}
public async Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false)
{
var query = _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.CatalogItem)
.AsQueryable();
if (!includeInactive)
query = query.Where(s => s.IsActive);
return await query
.OrderBy(s => s.Area)
.ThenBy(s => s.Row)
.ThenBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<StorageLocation?> GetByIdAsync(Guid id)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.CatalogItem)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<StorageLocation?> GetByCodeAsync(string code)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.CatalogItem)
.FirstOrDefaultAsync(s => s.Code == code);
}
public async Task<List<StorageLocation>> GetAvailableAsync()
{
return await _context.StorageLocations
.AsNoTracking()
.Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied)
.OrderBy(s => s.Area)
.ThenBy(s => s.Row)
.ThenBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<List<StorageLocation>> GetByAreaAsync(AreaType area)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Where(s => s.IsActive && s.Area == area)
.OrderBy(s => s.Row)
.ThenBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Where(s => s.IsActive && s.Area == area && s.Row == row)
.OrderBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<StorageLocation> CreateAsync(StorageLocation location)
{
// Ensure code is generated from components
location.GenerateCode();
// Validate the generated code
var validation = _locationCodeService.ValidateLocationCode(location.Code);
if (!validation.IsValid)
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
// Check for duplicate code
if (await _locationCodeService.CodeExistsAsync(location.Code))
throw new LocationCodeExistsException(location.Code);
location.CreatedAt = DateTime.UtcNow;
_context.StorageLocations.Add(location);
await _context.SaveChangesAsync();
_logger.LogInformation("Storage location created: {Code} ({Display})",
location.Code, location.DisplayName);
return location;
}
public async Task<StorageLocation> UpdateAsync(StorageLocation location)
{
// Regenerate code from components
location.GenerateCode();
// Validate the code
var validation = _locationCodeService.ValidateLocationCode(location.Code);
if (!validation.IsValid)
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
// Check for duplicate code (excluding this location)
var existingWithCode = await _context.StorageLocations
.FirstOrDefaultAsync(l => l.Code == location.Code && l.Id != location.Id);
if (existingWithCode != null)
throw new LocationCodeExistsException(location.Code);
location.UpdatedAt = DateTime.UtcNow;
_context.StorageLocations.Update(location);
await _context.SaveChangesAsync();
_logger.LogInformation("Storage location updated: {Code}", location.Code);
return location;
}
public async Task<bool> DeleteAsync(Guid id)
{
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == id);
if (location == null)
return false;
// Cannot delete occupied location
if (location.IsOccupied)
{
_logger.LogWarning("Cannot delete storage location {Code} - is occupied", location.Code);
return false;
}
// Soft delete
location.IsActive = false;
location.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Storage location deactivated: {Code}", location.Code);
return true;
}
public async Task<List<AreaType>> GetAreasAsync()
{
var areas = await _context.StorageLocations
.Where(s => s.IsActive)
.Select(s => s.Area)
.Distinct()
.ToListAsync();
return areas.OrderBy(a => (int)a).ToList();
}
public async Task<List<int>> GetRowsAsync(AreaType area)
{
return await _context.StorageLocations
.Where(s => s.IsActive && s.Area == area)
.Select(s => s.Row)
.Distinct()
.OrderBy(r => r)
.ToListAsync();
}
public async Task<bool> IsLocationAvailableAsync(Guid locationId)
{
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == locationId);
if (location == null || !location.IsActive || !location.IsAvailable)
return false;
return !location.IsOccupied;
}
public async Task<int> GetOccupancyAsync(Guid locationId)
{
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == locationId);
return location?.IsOccupied == true ? 1 : 0;
}
public async Task AssignTireStackAsync(string locationCode, Guid tireStackId)
{
var location = await GetByCodeAsync(locationCode)
?? throw new LocationNotFoundException(locationCode);
if (location.IsOccupied)
throw new LocationOccupiedException(locationCode, location.TireStackId!.Value);
if (!location.IsAvailable)
throw new InvalidOperationException($"Lagerplatz {locationCode} ist gesperrt.");
location.AssignTireStack(tireStackId);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack {TireStackId} assigned to location {Code}",
tireStackId, locationCode);
}
public async Task ClearLocationAsync(string locationCode)
{
var location = await GetByCodeAsync(locationCode)
?? throw new LocationNotFoundException(locationCode);
var previousTireStackId = location.TireStackId;
location.ClearLocation();
await _context.SaveChangesAsync();
_logger.LogInformation("Location {Code} cleared (previous tire stack: {TireStackId})",
locationCode, previousTireStackId);
}
public async Task MoveStackAsync(string fromCode, string toCode)
{
var fromLocation = await GetByCodeAsync(fromCode)
?? throw new LocationNotFoundException(fromCode);
var toLocation = await GetByCodeAsync(toCode)
?? throw new LocationNotFoundException(toCode);
if (!fromLocation.IsOccupied)
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
if (toLocation.IsOccupied)
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
if (!toLocation.IsAvailable)
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
var tireStackId = fromLocation.TireStackId!.Value;
// Update the tire stack's storage location reference
var tireStack = await _context.TireStacks.FindAsync(tireStackId);
if (tireStack != null)
{
tireStack.StorageLocationId = toLocation.Id;
tireStack.UpdatedAt = DateTime.UtcNow;
}
// Clear source and assign to destination
fromLocation.ClearLocation();
toLocation.AssignTireStack(tireStackId);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack {TireStackId} moved from {FromCode} to {ToCode}",
tireStackId, fromCode, toCode);
}
public async Task<OccupancyStatistics> GetOccupancyStatisticsAsync()
{
var locations = await _context.StorageLocations
.Where(l => l.IsActive)
.ToListAsync();
var locationsByRow = locations
.GroupBy(l => l.Row)
.ToDictionary(g => g.Key, g => g.Count());
var occupiedByRow = locations
.Where(l => l.IsOccupied)
.GroupBy(l => l.Row)
.ToDictionary(g => g.Key, g => g.Count());
return new OccupancyStatistics(
TotalLocations: locations.Count,
OccupiedLocations: locations.Count(l => l.IsOccupied),
AvailableLocations: locations.Count(l => !l.IsOccupied && l.IsAvailable),
BlockedLocations: locations.Count(l => !l.IsAvailable),
TotalByLager: locations.Count(l => l.Area == AreaType.Lager),
OccupiedByLager: locations.Count(l => l.Area == AreaType.Lager && l.IsOccupied),
TotalByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt),
OccupiedByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt && l.IsOccupied),
LocationsByRow: locationsByRow,
OccupiedByRow: occupiedByRow
);
}
public async Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot)
{
var maxRow = area.GetMaxRow();
if (row < StorageLocation.MinRow || row > maxRow)
throw new ArgumentOutOfRangeException(nameof(row),
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
if (fromSlot < StorageLocation.MinSlot || toSlot > StorageLocation.MaxSlot || fromSlot > toSlot)
throw new ArgumentOutOfRangeException(nameof(fromSlot),
$"Plätze müssen zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
var existingCodes = await _context.StorageLocations
.Where(l => l.Area == area && l.Row == row)
.Select(l => l.Code)
.ToListAsync();
var existingSet = existingCodes.ToHashSet();
var createdLocations = new List<StorageLocation>();
for (int slot = fromSlot; slot <= toSlot; slot++)
{
foreach (var position in ShelfPositionExtensions.All)
{
var location = new StorageLocation
{
Area = area,
Row = row,
Position = position,
SlotNumber = slot
};
location.GenerateCode();
if (existingSet.Contains(location.Code))
{
_logger.LogDebug("Skipping existing location: {Code}", location.Code);
continue;
}
_context.StorageLocations.Add(location);
createdLocations.Add(location);
}
}
if (createdLocations.Count > 0)
{
await _context.SaveChangesAsync();
_logger.LogInformation("Created {Count} storage locations in {Area} Row {Row}",
createdLocations.Count, area.GetDisplayName(), row);
}
return createdLocations;
}
public async Task<int> SeedDefaultLocationsAsync()
{
var existingCodes = (await _context.StorageLocations
.Select(l => l.Code)
.ToListAsync()).ToHashSet();
var created = 0;
foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
{
for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
{
foreach (var position in ShelfPositionExtensions.All)
{
for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
var location = new StorageLocation
{
Area = area,
Row = row,
Position = position,
SlotNumber = slot
};
location.GenerateCode();
if (existingCodes.Contains(location.Code))
continue;
_context.StorageLocations.Add(location);
created++;
}
}
}
}
if (created > 0)
{
await _context.SaveChangesAsync();
_logger.LogInformation("Seeded {Count} default storage locations", created);
}
return created;
}
}
+504
View File
@@ -0,0 +1,504 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Service interface for managing tire stacks
/// </summary>
public interface ITireStackService
{
Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null);
Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory);
Task<TireStack?> GetByIdAsync(Guid id);
Task<TireStack?> GetByTicketNumberAsync(string ticketNumber);
Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null);
Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null);
Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId);
Task<List<TireStack>> SearchAsync(string searchTerm);
Task<TireStack> CreateAsync(TireStack tireStack);
Task<TireStack> UpdateAsync(TireStack tireStack);
Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
Task<bool> DeleteAsync(Guid id);
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
Task<StorageStatistics> GetStatisticsAsync();
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
}
/// <summary>
/// Statistics for the tire storage system
/// </summary>
public class StorageStatistics
{
/// <summary>
/// Total number of stacks currently stored
/// </summary>
public int TotalStacksStored { get; set; }
/// <summary>
/// Total number of individual tires stored (stacks * 4)
/// </summary>
public int TotalTiresStored => TotalStacksStored * TireStack.TiresPerStack;
/// <summary>
/// Total stacks picked up (historical)
/// </summary>
public int TotalStacksPickedUp { get; set; }
/// <summary>
/// Number of active customers
/// </summary>
public int TotalCustomers { get; set; }
/// <summary>
/// Number of available storage locations
/// </summary>
public int AvailableLocations { get; set; }
/// <summary>
/// Number of occupied storage locations
/// </summary>
public int OccupiedLocations { get; set; }
/// <summary>
/// Summer tire stacks stored
/// </summary>
public int SummerStacksStored { get; set; }
/// <summary>
/// Winter tire stacks stored
/// </summary>
public int WinterStacksStored { get; set; }
/// <summary>
/// All-season tire stacks stored
/// </summary>
public int AllSeasonStacksStored { get; set; }
/// <summary>
/// Inventory stacks (shop stock)
/// </summary>
public int InventoryStackCount { get; set; }
/// <summary>
/// Customer tire stacks
/// </summary>
public int CustomerStackCount { get; set; }
}
/// <summary>
/// Service implementation for managing tire stacks
/// </summary>
public class TireStackService : ITireStackService
{
private readonly AppDbContext _context;
private readonly ILogger<TireStackService> _logger;
public TireStackService(AppDbContext context, ILogger<TireStackService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null)
{
return await GetAllAsync(status, null);
}
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory)
{
var query = _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.IsActive);
if (status.HasValue)
query = query.Where(ts => ts.Status == status.Value);
if (isInventory.HasValue)
{
if (isInventory.Value)
query = query.Where(ts => ts.CustomerId == null);
else
query = query.Where(ts => ts.CustomerId != null);
}
return await query
.OrderByDescending(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<TireStack?> GetByIdAsync(Guid id)
{
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.FirstOrDefaultAsync(ts => ts.Id == id && ts.IsActive);
}
public async Task<TireStack?> GetByTicketNumberAsync(string ticketNumber)
{
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.FirstOrDefaultAsync(ts => ts.TicketNumber == ticketNumber && ts.IsActive);
}
public async Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null)
{
var query = _context.TireStacks
.Include(ts => ts.StorageLocation)
.Include(ts => ts.Vehicle)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.CustomerId == customerId && ts.IsActive);
if (status.HasValue)
query = query.Where(ts => ts.Status == status.Value);
return await query
.OrderByDescending(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null)
{
var query = _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.CatalogItemId == catalogItemId && ts.IsActive);
if (status.HasValue)
query = query.Where(ts => ts.Status == status.Value);
return await query
.OrderByDescending(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId)
{
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.StorageLocationId == locationId && ts.Status == TireStackStatus.Stored && ts.IsActive)
.OrderBy(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<List<TireStack>> SearchAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllAsync(TireStackStatus.Stored);
var term = searchTerm.Trim().ToLower();
return await _context.TireStacks
.AsNoTracking()
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && (
ts.TicketNumber.ToLower().Contains(term) ||
(ts.TireBrand != null && ts.TireBrand.ToLower().Contains(term)) ||
(ts.TireSize != null && ts.TireSize.ToLower().Contains(term)) ||
(ts.Customer != null && ts.Customer.FirstName != null && ts.Customer.FirstName.ToLower().Contains(term)) ||
(ts.Customer != null && ts.Customer.LastName.ToLower().Contains(term)) ||
(ts.Customer != null && ts.Customer.CustomerNumber.ToLower().Contains(term)) ||
(ts.Vehicle != null && ts.Vehicle.Make.ToLower().Contains(term)) ||
(ts.Vehicle != null && ts.Vehicle.Model != null && ts.Vehicle.Model.ToLower().Contains(term)) ||
(ts.StorageLocation != null && ts.StorageLocation.Code.ToLower().Contains(term)) ||
(ts.CatalogItem != null && ts.CatalogItem.Name.ToLower().Contains(term))
))
.OrderByDescending(ts => ts.StoredAt)
.Take(50)
.ToListAsync();
}
public async Task<TireStack> CreateAsync(TireStack tireStack)
{
var isInventory = tireStack.CustomerId == null;
if (string.IsNullOrEmpty(tireStack.TicketNumber))
tireStack.TicketNumber = await GenerateTicketNumberAsync(isInventory);
tireStack.CreatedAt = DateTime.UtcNow;
tireStack.StoredAt = DateTime.UtcNow;
_context.TireStacks.Add(tireStack);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack created: {TicketNumber}, IsInventory: {IsInventory}",
tireStack.TicketNumber, isInventory);
return tireStack;
}
public async Task<TireStack> UpdateAsync(TireStack tireStack)
{
tireStack.UpdatedAt = DateTime.UtcNow;
_context.TireStacks.Update(tireStack);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack updated: {TicketNumber}", tireStack.TicketNumber);
return tireStack;
}
public async Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null)
{
// Verify storage location is available (force fresh values in case a
// stale tracked instance exists in this long-lived scoped context)
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == storageLocationId);
if (location == null)
throw new InvalidOperationException("Lagerplatz nicht gefunden.");
await _context.Entry(location).ReloadAsync();
if (!location.IsActive || !location.IsAvailable)
throw new InvalidOperationException("Lagerplatz ist nicht verfügbar.");
// AssignTireStack handles the IsOccupied check and sets TireStackId/IsOccupied/OccupiedSince
location.AssignTireStack(tireStack.Id);
tireStack.StorageLocationId = storageLocationId;
tireStack.Status = TireStackStatus.Stored;
tireStack.StoredAt = DateTime.UtcNow;
tireStack.StoredByUserId = storedByUserId;
tireStack.UpdatedAt = DateTime.UtcNow;
_context.TireStacks.Update(tireStack);
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException($"Lagerplatz {location.Code} wurde soeben anderweitig belegt.");
}
_logger.LogInformation("Tire stack {TicketNumber} stored at location {LocationCode}",
tireStack.TicketNumber, location.Code);
return tireStack;
}
public async Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null)
{
var tireStack = await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.StorageLocation)
.FirstOrDefaultAsync(ts => ts.Id == tireStackId);
if (tireStack == null)
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved)
throw new InvalidOperationException("Reifenstapel ist nicht eingelagert.");
// Clear the storage location (both sides)
if (tireStack.StorageLocation != null)
tireStack.StorageLocation.ClearLocation();
tireStack.Status = TireStackStatus.PickedUp;
tireStack.PickedUpAt = DateTime.UtcNow;
tireStack.PickedUpByUserId = pickedUpByUserId;
tireStack.StorageLocationId = null;
tireStack.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack {TicketNumber} picked up", tireStack.TicketNumber);
return tireStack;
}
public async Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null)
{
// MySqlRetryingExecutionStrategy requires user transactions to run
// inside the execution strategy as a single retriable unit.
var hadTicketNumber = !string.IsNullOrEmpty(tireStack.TicketNumber);
var strategy = _context.Database.CreateExecutionStrategy();
try
{
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var created = await CreateAsync(tireStack);
var stored = await StoreAsync(created, storageLocationId, storedByUserId);
await transaction.CommitAsync();
return stored;
}
catch
{
await transaction.RollbackAsync();
// Detach the failed entities so a retry (by the strategy or
// the user) does not re-insert stale Added/Modified state.
_context.ChangeTracker.Clear();
// Regenerate the ticket number on retry if we created it here
// (avoids permanent unique-index collisions).
if (!hadTicketNumber)
tireStack.TicketNumber = string.Empty;
throw;
}
});
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Der Lagerplatz wurde soeben anderweitig belegt. Bitte anderen Platz wählen.");
}
}
public async Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null)
{
var ids = tireStackIds.Distinct().ToList();
if (ids.Count == 0)
return new List<TireStack>();
var strategy = _context.Database.CreateExecutionStrategy();
try
{
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var pickedUp = new List<TireStack>(ids.Count);
foreach (var id in ids)
{
pickedUp.Add(await PickupAsync(id, pickedUpByUserId));
}
await transaction.CommitAsync();
return pickedUp;
}
catch
{
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
throw;
}
});
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Ein Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen.");
}
}
public async Task<bool> DeleteAsync(Guid id)
{
var tireStack = await _context.TireStacks.FindAsync(id);
if (tireStack == null)
return false;
// Block deletion of stored or reserved tire stacks
if (tireStack.Status == TireStackStatus.Stored || tireStack.Status == TireStackStatus.Reserved)
{
_logger.LogWarning("Cannot delete {Status} tire stack {TicketNumber}", tireStack.Status, tireStack.TicketNumber);
return false;
}
// Soft delete
tireStack.IsActive = false;
tireStack.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack soft-deleted: {TicketNumber}", tireStack.TicketNumber);
return true;
}
public async Task<string> GenerateTicketNumberAsync(bool isInventory = false)
{
var year = DateTime.UtcNow.Year;
var prefix = isInventory ? $"INV-{year}-" : $"TS-{year}-";
var lastNumber = await _context.TireStacks
.Where(ts => ts.TicketNumber.StartsWith(prefix))
.OrderByDescending(ts => ts.TicketNumber)
.Select(ts => ts.TicketNumber)
.FirstOrDefaultAsync();
var nextNumber = 1;
if (lastNumber != null)
{
var numPart = lastNumber.Replace(prefix, "");
if (int.TryParse(numPart, out var lastNum))
nextNumber = lastNum + 1;
}
return $"{prefix}{nextNumber:D5}";
}
public async Task<StorageStatistics> GetStatisticsAsync()
{
var storedStacks = await _context.TireStacks
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored)
.GroupBy(ts => ts.Season)
.Select(g => new { Season = g.Key, Count = g.Count() })
.ToListAsync();
var totalLocations = await _context.StorageLocations
.Where(s => s.IsActive)
.CountAsync();
var occupiedLocations = await _context.TireStacks
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.StorageLocationId != null)
.Select(ts => ts.StorageLocationId)
.Distinct()
.CountAsync();
var inventoryCount = await _context.TireStacks
.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId == null);
var customerStackCount = await _context.TireStacks
.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId != null);
return new StorageStatistics
{
TotalStacksStored = storedStacks.Sum(x => x.Count),
TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp),
TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive),
AvailableLocations = totalLocations - occupiedLocations,
OccupiedLocations = occupiedLocations,
SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0,
WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0,
AllSeasonStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Allrad)?.Count ?? 0,
InventoryStackCount = inventoryCount,
CustomerStackCount = customerStackCount
};
}
public async Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14)
{
var cutoffDate = DateTime.UtcNow.AddDays(daysAhead);
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.StorageLocation)
.Where(ts => ts.IsActive &&
ts.Status == TireStackStatus.Stored &&
ts.CustomerId != null &&
ts.ExpectedPickupDate != null &&
ts.ExpectedPickupDate <= cutoffDate)
.OrderBy(ts => ts.ExpectedPickupDate)
.ToListAsync();
}
}
+243
View File
@@ -0,0 +1,243 @@
using System.Net;
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text;
using Lagerbestand.Models;
using QRCoder;
namespace Lagerbestand.Services;
public interface ITwoFactorService
{
/// <summary>
/// Generates a new random Base32-encoded TOTP secret key.
/// </summary>
string GenerateSecretKey();
/// <summary>
/// Generates the otpauth:// URI for authenticator app setup.
/// </summary>
string GenerateOtpAuthUri(string secret, string username, string issuer);
/// <summary>
/// Validates a 6-digit TOTP code against a secret (±1 time step tolerance).
/// </summary>
bool ValidateTotpCode(string secret, string code);
/// <summary>
/// Generates a random 6-digit email verification code.
/// </summary>
string GenerateEmailCode();
/// <summary>
/// Sends a 2FA code via email using configured SMTP settings.
/// </summary>
Task<bool> SendEmailCodeAsync(string toEmail, string code, string username);
/// <summary>
/// Generates a QR code as a base64 PNG data URL from an otpauth URI.
/// </summary>
string GenerateQrCodeDataUrl(string otpAuthUri);
}
public class TwoFactorService : ITwoFactorService
{
private const int SecretKeyLength = 20; // 160 bits
private const int TotpCodeLength = 6;
private const int TotpTimeStepSeconds = 30;
private const int TotpTimeTolerance = 1; // ±1 step (30 seconds each side)
private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray();
private readonly ISiteSettingsService _siteSettingsService;
private readonly ILogger<TwoFactorService> _logger;
public TwoFactorService(ISiteSettingsService siteSettingsService, ILogger<TwoFactorService> logger)
{
_siteSettingsService = siteSettingsService;
_logger = logger;
}
public string GenerateSecretKey()
{
var secretBytes = RandomNumberGenerator.GetBytes(SecretKeyLength);
return Base32Encode(secretBytes);
}
public string GenerateOtpAuthUri(string secret, string username, string issuer)
{
var encodedIssuer = Uri.EscapeDataString(issuer);
var encodedUsername = Uri.EscapeDataString(username);
return $"otpauth://totp/{encodedIssuer}:{encodedUsername}?secret={secret}&issuer={encodedIssuer}&digits={TotpCodeLength}&period={TotpTimeStepSeconds}";
}
public bool ValidateTotpCode(string secret, string code)
{
if (string.IsNullOrWhiteSpace(code) || code.Length != TotpCodeLength)
return false;
var secretBytes = Base32Decode(secret);
var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Check current time step and ±tolerance steps
for (int i = -TotpTimeTolerance; i <= TotpTimeTolerance; i++)
{
var timeStep = (unixTimestamp / TotpTimeStepSeconds) + i;
var generatedCode = ComputeTotp(secretBytes, timeStep);
if (CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(generatedCode),
Encoding.UTF8.GetBytes(code)))
{
return true;
}
}
return false;
}
public string GenerateEmailCode()
{
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
}
public async Task<bool> SendEmailCodeAsync(string toEmail, string code, string username)
{
try
{
var smtpSettings = await _siteSettingsService.GetSmtpSettingsAsync();
if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured)
{
_logger.LogWarning("SMTP is not configured. Cannot send 2FA email to {Email}", toEmail);
return false;
}
using var client = new SmtpClient(smtpSettings.Host, smtpSettings.Port)
{
Credentials = new NetworkCredential(smtpSettings.Username, smtpSettings.Password),
EnableSsl = smtpSettings.UseSsl,
Timeout = 15_000
};
using var message = new MailMessage
{
From = new MailAddress(smtpSettings.FromEmail, smtpSettings.FromName),
Subject = $"Ihr Anmeldecode – {code}",
IsBodyHtml = true,
Body = $"""
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2 style="color: #dc2626;">Reifen Späth – Anmeldecode</h2>
<p>Hallo <strong>{username}</strong>,</p>
<p>Ihr Einmal-Code für die Anmeldung lautet:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 16px; text-align: center; margin: 16px 0;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 6px; color: #111;">{code}</span>
</div>
<p style="color: #666; font-size: 14px;">Dieser Code ist 5 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
</div>
"""
};
message.To.Add(toEmail);
await client.SendMailAsync(message);
_logger.LogInformation("2FA email code sent to {Email} for user {Username}", toEmail, username);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send 2FA email to {Email}", toEmail);
return false;
}
}
public string GenerateQrCodeDataUrl(string otpAuthUri)
{
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(otpAuthUri, QRCodeGenerator.ECCLevel.M);
using var qrCode = new PngByteQRCode(qrCodeData);
var pngBytes = qrCode.GetGraphic(8, new byte[] { 30, 30, 32 }, new byte[] { 255, 255, 255 });
return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}";
}
#region TOTP Internals
private static string ComputeTotp(byte[] secret, long timeStep)
{
var counterBytes = BitConverter.GetBytes(timeStep);
if (BitConverter.IsLittleEndian)
Array.Reverse(counterBytes);
var hash = HMACSHA1.HashData(secret, counterBytes);
var offset = hash[^1] & 0x0F;
var code = ((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
return (code % 1_000_000).ToString("D6");
}
#endregion
#region Base32
private static string Base32Encode(byte[] data)
{
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = data[0];
int next = 1;
int bitsLeft = 8;
while (bitsLeft > 0 || next < data.Length)
{
if (bitsLeft < 5)
{
if (next < data.Length)
{
buffer <<= 8;
buffer |= data[next++] & 0xFF;
bitsLeft += 8;
}
else
{
int pad = 5 - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
int index = 0x1F & (buffer >> (bitsLeft - 5));
bitsLeft -= 5;
sb.Append(Base32Chars[index]);
}
return sb.ToString();
}
private static byte[] Base32Decode(string base32)
{
var cleanInput = base32.TrimEnd('=').ToUpperInvariant();
var output = new byte[cleanInput.Length * 5 / 8];
int buffer = 0;
int bitsLeft = 0;
int outputIndex = 0;
foreach (var c in cleanInput)
{
var val = Array.IndexOf(Base32Chars, c);
if (val < 0) continue;
buffer <<= 5;
buffer |= val & 0x1F;
bitsLeft += 5;
if (bitsLeft >= 8)
{
output[outputIndex++] = (byte)(buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return output;
}
#endregion
}
+113
View File
@@ -0,0 +1,113 @@
using Lagerbestand.Models;
namespace Lagerbestand.Services;
/// <summary>
/// Simple session service to hold current user state.
/// Registered as Scoped - one instance per circuit/connection.
/// </summary>
public class UserSessionService
{
public AppUser? CurrentUser { get; private set; }
public bool IsAuthenticated => CurrentUser != null && CurrentUser.IsActive;
public bool IsAdmin => CurrentUser?.Role == UserRole.Admin;
/// <summary>
/// Worker role - has access to tire operations (checkin/checkout) and inventory list only
/// </summary>
public bool IsWorker => CurrentUser?.Role == UserRole.Worker;
/// <summary>
/// Check if user is Worker or Admin (for tire operations access)
/// </summary>
public bool IsWorkerOrAdmin => CurrentUser?.Role == UserRole.Admin || CurrentUser?.Role == UserRole.Worker;
public string DisplayName => CurrentUser?.DisplayName ?? CurrentUser?.Username ?? string.Empty;
public string Username => CurrentUser?.Username ?? string.Empty;
public Guid? UserId => CurrentUser?.Id;
public UserRole? Role => CurrentUser?.Role;
public string RoleName => CurrentUser?.Role switch
{
UserRole.Admin => "Administrator",
UserRole.Worker => "Mitarbeiter",
UserRole.User => "Benutzer",
_ => string.Empty
};
public event Action? OnChange;
public void SetUser(AppUser user)
{
CurrentUser = user;
NotifyStateChanged();
}
public void ClearUser()
{
CurrentUser = null;
NotifyStateChanged();
}
/// <summary>
/// Updates the current user data without triggering OnChange.
/// Useful for refreshing user data after profile updates.
/// </summary>
public void UpdateUser(AppUser user)
{
if (CurrentUser?.Id == user.Id)
{
CurrentUser = user;
NotifyStateChanged();
}
}
/// <summary>
/// Checks if the current user has at least the specified role level.
/// Admin > Worker > User
/// </summary>
public bool HasMinimumRole(UserRole minimumRole)
{
if (CurrentUser == null || !CurrentUser.IsActive)
return false;
return (int)CurrentUser.Role >= (int)minimumRole;
}
/// <summary>
/// Checks if the current user can manage the specified user.
/// Users can only be managed by admins or by themselves for limited operations.
/// </summary>
public bool CanManageUser(Guid targetUserId)
{
if (!IsAuthenticated)
return false;
// Admins can manage everyone
if (IsAdmin)
return true;
// Users can only manage themselves (limited)
return CurrentUser?.Id == targetUserId;
}
/// <summary>
/// Checks if the current user can delete the specified user.
/// Only admins can delete users, and they cannot delete themselves.
/// </summary>
public bool CanDeleteUser(Guid targetUserId)
{
if (!IsAdmin)
return false;
// Admins cannot delete themselves
return CurrentUser?.Id != targetUserId;
}
private void NotifyStateChanged() => OnChange?.Invoke();
}
+16
View File
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=10.10.10.50;Port=3306;Database=test;User=root;Password=DDsm4$n3;Connect Timeout=30;Default Command Timeout=60;Pooling=true;Min Pool Size=0;Max Pool Size=100;Connection Lifetime=0;"
},
"AdminUser": {
"Username": "spaethf",
"Password": "DDsm4$n3",
"DisplayName": "Administrator"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:5000"
}
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=db.spaeth-bayern.de;Port=3306;Database=lagerbestand;User=root;Password=DDsm4$n3;Connect Timeout=60;Default Command Timeout=120;Pooling=true;Min Pool Size=0;Max Pool Size=100;Connection Lifetime=0;SslMode=Preferred;"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=db.spaeth-bayern.de;Port=3306;Database=lagerbestand;User=root;Password=DDsm4$n3;Connect Timeout=30;Default Command Timeout=60;Pooling=true;Min Pool Size=0;Max Pool Size=100;Connection Lifetime=0;"
},
"AdminUser": {
"Username": "spaethf",
"Password": "DDsm4$n3",
"DisplayName": "Administrator"
}
}
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
@charset "UTF-8";/*!
* Bootstrap v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){leLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
@@ -0,0 +1 @@
{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBAAA;;;;;ACAA,MAQI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EACA,cAAA,EAAA,CAAA,EAAA,CAAA,GAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAQA,sBAAA,0BACA,oBAAA,KACA,sBAAA,IACA,sBAAA,IACA,gBAAA,QAIA,aAAA,KClCF,EC+CA,QADA,SD3CE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BEmPI,UAAA,yBFjPJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YAUF,GACE,OAAA,KAAA,EACA,MAAA,QACA,iBAAA,aACA,OAAA,EACA,QAAA,IAGF,eACE,OAAA,IAUF,IAAA,IAAA,IAAA,IAAA,IAAA,IAAA,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IAIF,IAAA,GEwMQ,UAAA,uBAlKJ,0BFtCJ,IAAA,GE+MQ,UAAA,QF1MR,IAAA,GEmMQ,UAAA,sBAlKJ,0BFjCJ,IAAA,GE0MQ,UAAA,MFrMR,IAAA,GE8LQ,UAAA,oBAlKJ,0BF5BJ,IAAA,GEqMQ,UAAA,SFhMR,IAAA,GEyLQ,UAAA,sBAlKJ,0BFvBJ,IAAA,GEgMQ,UAAA,QF3LR,IAAA,GEgLM,UAAA,QF3KN,IAAA,GE2KM,UAAA,KFhKN,EACE,WAAA,EACA,cAAA,KCmBF,6BDRA,YAEE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GCIA,GDFE,aAAA,KCQF,GDLA,GCIA,GDDE,WAAA,EACA,cAAA,KAGF,MCKA,MACA,MAFA,MDAE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,ECNA,ODQE,YAAA,OAQF,OAAA,ME4EM,UAAA,OFrEN,MAAA,KACE,QAAA,KACA,iBAAA,QASF,ICpBA,IDsBE,SAAA,SEwDI,UAAA,MFtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,QACA,gBAAA,UAEA,QACE,MAAA,QAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KCxBJ,KACA,ID8BA,IC7BA,KDiCE,YAAA,yBEcI,UAAA,IFZJ,UAAA,IACA,aAAA,cAOF,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KEAI,UAAA,OFKJ,SELI,UAAA,QFOF,MAAA,QACA,WAAA,OAIJ,KEZM,UAAA,OFcJ,MAAA,QACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,MAAA,MExBI,UAAA,OF0BJ,MAAA,KACA,iBAAA,QG7SE,cAAA,MHgTF,QACE,QAAA,EE/BE,UAAA,IFiCF,YAAA,IASJ,OACE,OAAA,EAAA,EAAA,KAMF,ICjDA,IDmDE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,QACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBCxDF,MAGA,GAFA,MAGA,GDuDA,MCzDA,GD+DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,ECtEF,OD2EA,MCzEA,SADA,OAEA,SD6EE,OAAA,EACA,YAAA,QE9HI,UAAA,QFgIJLine truncated
+217
View File
@@ -0,0 +1,217 @@
/*
* Reifen Späth – App Interop styles
* Reconnect overlay + idle-lock overlay.
* Uses global design tokens from app.css.
*/
/* =============================================
Blazor reconnect overlay (replaces default)
The framework toggles styles on:
#components-reconnect-modal
via classes: components-reconnect-show / hide / failed / rejected
============================================= */
#components-reconnect-modal {
display: none;
position: fixed;
inset: 0;
z-index: 2147483000;
align-items: center;
justify-content: center;
padding: var(--space-4, 1rem);
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
font-family: var(--font-family-base, sans-serif);
color: var(--text-primary, #fff);
}
#components-reconnect-modal.components-reconnect-show,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-rejected {
display: flex;
}
#components-reconnect-modal .reconnect-dialog {
background: var(--surface-primary, rgba(28, 28, 30, 0.95));
border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1));
border-radius: var(--radius-xl, 20px);
box-shadow: var(--shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.5));
padding: var(--space-6, 1.5rem);
width: 100%;
max-width: 420px;
text-align: center;
}
#components-reconnect-modal .reconnect-icon {
width: 56px;
height: 56px;
margin: 0 auto var(--space-4, 1rem);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(220, 38, 38, 0.15);
color: var(--color-primary-light, #ef4444);
}
#components-reconnect-modal .reconnect-icon svg {
animation: reconnect-spin 1.4s linear infinite;
}
@keyframes reconnect-spin {
to { transform: rotate(360deg); }
}
#components-reconnect-modal h2 {
margin: 0 0 var(--space-2, 0.5rem);
font-size: var(--text-xl, 1.25rem);
font-family: var(--font-family-base, sans-serif);
font-weight: var(--font-weight-semibold, 600);
}
#components-reconnect-modal p {
margin: 0 0 var(--space-5, 1.25rem);
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
font-size: var(--text-sm, 0.875rem);
line-height: var(--line-height-relaxed, 1.6);
}
#components-reconnect-modal .reconnect-actions {
display: flex;
flex-direction: column;
gap: var(--space-3, 0.75rem);
}
#components-reconnect-modal .reconnect-text-show,
#components-reconnect-modal .reconnect-text-failed,
#components-reconnect-modal .reconnect-text-rejected,
#components-reconnect-modal .reconnect-actions-failed {
display: none;
}
#components-reconnect-modal.components-reconnect-show .reconnect-text-show { display: block; }
#components-reconnect-modal.components-reconnect-failed .reconnect-text-failed,
#components-reconnect-modal.components-reconnect-failed .reconnect-actions-failed { display: block; }
#components-reconnect-modal.components-reconnect-failed .reconnect-actions-failed { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
#components-reconnect-modal.components-reconnect-rejected .reconnect-text-rejected,
#components-reconnect-modal.components-reconnect-rejected .reconnect-actions-failed { display: block; }
#components-reconnect-modal.components-reconnect-rejected .reconnect-actions-failed { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
/* Hide default retry countdown */
#components-reconnect-modal .components-reconnect-current-attempt,
#components-reconnect-modal .components-reconnect-max-retries {
color: var(--text-tertiary, rgba(255, 255, 255, 0.5));
font-size: var(--text-xs, 0.75rem);
}
#components-reconnect-modal button {
min-height: var(--touch-target, 44px);
padding: var(--space-3, 0.75rem) var(--space-5, 1.25rem);
border-radius: var(--radius-md, 10px);
border: none;
font-family: var(--font-family-base, sans-serif);
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-weight-semibold, 600);
cursor: pointer;
transition: all var(--duration-fast, 150ms) var(--ease-out, ease);
}
#components-reconnect-modal .reconnect-retry {
background: linear-gradient(180deg, var(--color-primary-light, #ef4444) 0%, var(--color-primary, #dc2626) 100%);
color: var(--text-primary, #fff);
box-shadow: var(--shadow-btn-primary, 0 4px 12px rgba(220, 38, 38, 0.3));
}
#components-reconnect-modal .reconnect-reload {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary, #fff);
border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1));
}
#components-reconnect-modal button:hover:not(:disabled) {
transform: translateY(-1px);
}
/* =============================================
Idle lock overlay
============================================= */
#app-idle-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 2147482000;
align-items: center;
justify-content: center;
padding: var(--space-4, 1rem);
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
font-family: var(--font-family-base, sans-serif);
color: var(--text-primary, #fff);
animation: fadeIn var(--duration-normal, 250ms) var(--ease-out, ease);
}
#app-idle-overlay.visible {
display: flex;
}
#app-idle-overlay .app-idle-dialog {
background: var(--surface-primary, rgba(28, 28, 30, 0.95));
border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1));
border-radius: var(--radius-xl, 20px);
box-shadow: var(--shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.5));
padding: var(--space-6, 1.5rem);
width: 100%;
max-width: 420px;
text-align: center;
}
#app-idle-overlay .app-idle-icon {
width: 64px;
height: 64px;
margin: 0 auto var(--space-4, 1rem);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(251, 191, 36, 0.15);
color: var(--color-warning, #fbbf24);
}
#app-idle-overlay h2 {
margin: 0 0 var(--space-2, 0.5rem);
font-size: var(--text-xl, 1.25rem);
font-weight: var(--font-weight-semibold, 600);
}
#app-idle-overlay p {
margin: 0 0 var(--space-5, 1.25rem);
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
font-size: var(--text-sm, 0.875rem);
line-height: var(--line-height-relaxed, 1.6);
}
#app-idle-overlay .app-idle-resume {
min-height: var(--touch-target, 44px);
width: 100%;
padding: var(--space-3, 0.75rem) var(--space-5, 1.25rem);
border-radius: var(--radius-md, 10px);
border: none;
background: linear-gradient(180deg, var(--color-primary-light, #ef4444) 0%, var(--color-primary, #dc2626) 100%);
color: var(--text-primary, #fff);
font-family: var(--font-family-base, sans-serif);
font-size: var(--text-base, 1rem);
font-weight: var(--font-weight-semibold, 600);
cursor: pointer;
box-shadow: var(--shadow-btn-primary, 0 4px 12px rgba(220, 38, 38, 0.3));
transition: transform var(--duration-fast, 150ms) var(--ease-out, ease);
}
#app-idle-overlay .app-idle-resume:hover {
transform: translateY(-1px);
}
#app-idle-overlay .app-idle-resume:focus-visible {
outline: 3px solid rgba(220, 38, 38, 0.5);
outline-offset: 2px;
}
+7
View File
@@ -0,0 +1,7 @@
@charset "UTF-8";/*!
* Bootstrap v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){leLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
@@ -0,0 +1 @@
{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBAAA;;;;;ACAA,MAQI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EACA,cAAA,EAAA,CAAA,EAAA,CAAA,GAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAQA,sBAAA,0BACA,oBAAA,KACA,sBAAA,IACA,sBAAA,IACA,gBAAA,QAIA,aAAA,KClCF,EC+CA,QADA,SD3CE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BEmPI,UAAA,yBFjPJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YAUF,GACE,OAAA,KAAA,EACA,MAAA,QACA,iBAAA,aACA,OAAA,EACA,QAAA,IAGF,eACE,OAAA,IAUF,IAAA,IAAA,IAAA,IAAA,IAAA,IAAA,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IAIF,IAAA,GEwMQ,UAAA,uBAlKJ,0BFtCJ,IAAA,GE+MQ,UAAA,QF1MR,IAAA,GEmMQ,UAAA,sBAlKJ,0BFjCJ,IAAA,GE0MQ,UAAA,MFrMR,IAAA,GE8LQ,UAAA,oBAlKJ,0BF5BJ,IAAA,GEqMQ,UAAA,SFhMR,IAAA,GEyLQ,UAAA,sBAlKJ,0BFvBJ,IAAA,GEgMQ,UAAA,QF3LR,IAAA,GEgLM,UAAA,QF3KN,IAAA,GE2KM,UAAA,KFhKN,EACE,WAAA,EACA,cAAA,KCmBF,6BDRA,YAEE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GCIA,GDFE,aAAA,KCQF,GDLA,GCIA,GDDE,WAAA,EACA,cAAA,KAGF,MCKA,MACA,MAFA,MDAE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,ECNA,ODQE,YAAA,OAQF,OAAA,ME4EM,UAAA,OFrEN,MAAA,KACE,QAAA,KACA,iBAAA,QASF,ICpBA,IDsBE,SAAA,SEwDI,UAAA,MFtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,QACA,gBAAA,UAEA,QACE,MAAA,QAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KCxBJ,KACA,ID8BA,IC7BA,KDiCE,YAAA,yBEcI,UAAA,IFZJ,UAAA,IACA,aAAA,cAOF,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KEAI,UAAA,OFKJ,SELI,UAAA,QFOF,MAAA,QACA,WAAA,OAIJ,KEZM,UAAA,OFcJ,MAAA,QACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,MAAA,MExBI,UAAA,OF0BJ,MAAA,KACA,iBAAA,QG7SE,cAAA,MHgTF,QACE,QAAA,EE/BE,UAAA,IFiCF,YAAA,IASJ,OACE,OAAA,EAAA,EAAA,KAMF,ICjDA,IDmDE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,QACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBCxDF,MAGA,GAFA,MAGA,GDuDA,MCzDA,GD+DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,ECtEF,OD2EA,MCzEA,SADA,OAEA,SD6EE,OAAA,EACA,YAAA,QE9HI,UAAA,QFgIJLine truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@@ -0,0 +1 @@
google-site-verification: google6d5a1b5c60a38816.html
+334
View File
@@ -0,0 +1,334 @@
/*
* Reifen Späth – App Interop
* =============================================================
* Browser-side helpers for long-duration usage:
* - Visibility refresh (tab re-activated after N minutes)
* - Idle-lock overlay (confirm-to-continue after N minutes)
* - beforeunload warning when a form has unsaved draft data
* - sessionStorage draft helpers (per-tab)
* - focus-trap + Escape + click-outside helpers for modals/menus
* - Custom Blazor reconnect UI wiring (handled via CSS selectors
* on #components-reconnect-modal in App.razor)
* =============================================================
*/
(function () {
'use strict';
// ---------------------------------------------------------
// sessionStorage drafts (per-browser, per-tab)
// ---------------------------------------------------------
const DRAFT_PREFIX = 'rs-draft:';
window.appInterop = window.appInterop || {};
window.appInterop.saveDraft = function (key, json) {
try {
if (json == null) {
sessionStorage.removeItem(DRAFT_PREFIX + key);
return true;
}
sessionStorage.setItem(DRAFT_PREFIX + key, json);
return true;
} catch (e) {
console.warn('[appInterop] saveDraft failed', e);
return false;
}
};
window.appInterop.loadDraft = function (key) {
try {
return sessionStorage.getItem(DRAFT_PREFIX + key);
} catch (e) {
return null;
}
};
window.appInterop.clearDraft = function (key) {
try {
sessionStorage.removeItem(DRAFT_PREFIX + key);
} catch (e) { /* ignore */ }
};
// ---------------------------------------------------------
// beforeunload: warn user when draft flag is set
// ---------------------------------------------------------
let hasUnsavedChanges = false;
window.appInterop.setUnsaved = function (value) {
hasUnsavedChanges = !!value;
};
window.addEventListener('beforeunload', function (e) {
if (hasUnsavedChanges) {
e.preventDefault();
// Most browsers ignore the custom message and show a generic one.
e.returnValue = '';
return '';
}
});
// ---------------------------------------------------------
// Visibility refresh: fire .NET callback when tab returns
// after being hidden for more than `thresholdSeconds`.
//
// One .NET target per page; replacing cancels the previous.
// ---------------------------------------------------------
let visibilityHiddenAt = null;
let visibilityTarget = null; // DotNetObjectReference
let visibilityMethod = null;
let visibilityThresholdSec = 120;
function onVisibilityChange() {
if (document.visibilityState === 'hidden') {
visibilityHiddenAt = Date.now();
} else if (document.visibilityState === 'visible') {
if (!visibilityTarget || !visibilityHiddenAt) return;
const hiddenFor = (Date.now() - visibilityHiddenAt) / 1000;
visibilityHiddenAt = null;
if (hiddenFor >= visibilityThresholdSec) {
try {
visibilityTarget.invokeMethodAsync(visibilityMethod, Math.round(hiddenFor));
} catch (e) {
console.warn('[appInterop] visibility invoke failed', e);
}
}
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
window.appInterop.registerVisibilityRefresh = function (dotNetRef, methodName, thresholdSeconds) {
visibilityTarget = dotNetRef;
visibilityMethod = methodName || 'OnVisibilityReturn';
visibilityThresholdSec = thresholdSeconds > 0 ? thresholdSeconds : 120;
visibilityHiddenAt = null;
};
window.appInterop.unregisterVisibilityRefresh = function () {
visibilityTarget = null;
visibilityMethod = null;
visibilityHiddenAt = null;
};
// ---------------------------------------------------------
// Idle lock overlay (per-tab).
// Fires after N minutes of no user input; requires a click
// on the "Weiter" button to resume (no password — matches
// confirmed UX decision).
// ---------------------------------------------------------
const IDLE_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'wheel'];
let idleTimerId = null;
let idleTimeoutMs = 60 * 60 * 1000; // 60 min default
let idleActive = false;
let idleLocked = false;
function resetIdleTimer() {
if (!idleActive || idleLocked) return;
if (idleTimerId) clearTimeout(idleTimerId);
idleTimerId = setTimeout(triggerIdleLock, idleTimeoutMs);
}
function triggerIdleLock() {
idleLocked = true;
const overlay = document.getElementById('app-idle-overlay');
if (overlay) {
overlay.classList.add('visible');
const btn = overlay.querySelector('.app-idle-resume');
if (btn) {
setTimeout(function () { try { btn.focus(); } catch (e) { } }, 50);
}
}
}
function releaseIdleLock() {
idleLocked = false;
const overlay = document.getElementById('app-idle-overlay');
if (overlay) overlay.classList.remove('visible');
resetIdleTimer();
}
window.appInterop.startIdleWatcher = function (idleMinutes) {
const minutes = parseInt(idleMinutes, 10);
if (!minutes || minutes <= 0) {
window.appInterop.stopIdleWatcher();
return;
}
idleTimeoutMs = minutes * 60 * 1000;
if (!idleActive) {
idleActive = true;
IDLE_EVENTS.forEach(function (ev) {
window.addEventListener(ev, resetIdleTimer, { passive: true });
});
const btn = document.querySelector('#app-idle-overlay .app-idle-resume');
if (btn && !btn.dataset.bound) {
btn.dataset.bound = '1';
btn.addEventListener('click', releaseIdleLock);
}
}
resetIdleTimer();
};
window.appInterop.stopIdleWatcher = function () {
if (!idleActive) return;
idleActive = false;
if (idleTimerId) clearTimeout(idleTimerId);
idleTimerId = null;
IDLE_EVENTS.forEach(function (ev) {
window.removeEventListener(ev, resetIdleTimer);
});
releaseIdleLock();
};
// Auto-load session settings and start watcher once DOM is ready.
// Only activates if the body has [data-authenticated="true"], which
// MainLayout sets when a user is logged in.
function tryAutoStartIdle() {
if (document.body && document.body.dataset.authenticated === 'true') {
fetch('/api/session-settings', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (s) {
if (s && s.idleTimeoutMinutes > 0) {
window.appInterop.startIdleWatcher(s.idleTimeoutMinutes);
}
})
.catch(function () { /* non-fatal */ });
}
}
// Observe auth marker on <body> so the watcher (re)starts after login/logout.
document.addEventListener('DOMContentLoaded', function () {
tryAutoStartIdle();
if (document.body) {
const mo = new MutationObserver(function () { tryAutoStartIdle(); });
mo.observe(document.body, { attributes: true, attributeFilter: ['data-authenticated'] });
}
});
// ---------------------------------------------------------
// Click-outside for user-menu dropdown and mobile-menu.
// The Blazor component sets [data-click-outside-id="..."] on
// the open panel and exposes a .NET method we call back.
// ---------------------------------------------------------
let clickOutsideTarget = null;
let clickOutsideMethod = null;
let clickOutsideContainerSelector = null;
function onDocumentClick(e) {
if (!clickOutsideTarget || !clickOutsideContainerSelector) return;
const container = document.querySelector(clickOutsideContainerSelector);
if (!container) return;
if (!container.contains(e.target)) {
try {
clickOutsideTarget.invokeMethodAsync(clickOutsideMethod);
} catch (err) { /* circuit may be reconnecting */ }
}
}
window.appInterop.registerClickOutside = function (dotNetRef, methodName, containerSelector) {
clickOutsideTarget = dotNetRef;
clickOutsideMethod = methodName;
clickOutsideContainerSelector = containerSelector;
// Use capture phase so stopPropagation inside the panel still works for us.
document.addEventListener('mousedown', onDocumentClick, true);
document.addEventListener('touchstart', onDocumentClick, { capture: true, passive: true });
};
window.appInterop.unregisterClickOutside = function () {
clickOutsideTarget = null;
clickOutsideMethod = null;
clickOutsideContainerSelector = null;
document.removeEventListener('mousedown', onDocumentClick, true);
document.removeEventListener('touchstart', onDocumentClick, { capture: true });
};
// ---------------------------------------------------------
// Focus trap for modals (autofocus + Tab cycle + Escape).
// Caller passes the container selector; we remember the
// previously-focused element and restore it on release.
// ---------------------------------------------------------
const FOCUSABLE = [
'a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])',
'textarea:not([disabled])', 'button:not([disabled])', 'iframe',
'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex="-1"])'
].join(',');
let trapState = null;
function onTrapKey(e) {
if (!trapState) return;
if (e.key === 'Escape' && trapState.escapeMethod) {
try { trapState.dotNetRef.invokeMethodAsync(trapState.escapeMethod); } catch (err) { }
return;
}
if (e.key !== 'Tab') return;
const container = document.querySelector(trapState.selector);
if (!container) return;
const focusables = Array.prototype.filter.call(
container.querySelectorAll(FOCUSABLE),
function (el) { return el.offsetParent !== null; });
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
window.appInterop.trapFocus = function (selector, dotNetRef, escapeMethod) {
if (trapState) { try { document.removeEventListener('keydown', onTrapKey, true); } catch (e) { } }
trapState = {
selector: selector,
dotNetRef: dotNetRef || null,
escapeMethod: escapeMethod || null,
previouslyFocused: document.activeElement
};
document.addEventListener('keydown', onTrapKey, true);
// Autofocus the first tabbable element.
setTimeout(function () {
const container = document.querySelector(selector);
if (!container) return;
const focusables = Array.prototype.filter.call(
container.querySelectorAll(FOCUSABLE),
function (el) { return el.offsetParent !== null; });
if (focusables.length > 0) {
try { focusables[0].focus(); } catch (e) { }
}
}, 30);
};
window.appInterop.releaseFocusTrap = function () {
document.removeEventListener('keydown', onTrapKey, true);
if (trapState && trapState.previouslyFocused && typeof trapState.previouslyFocused.focus === 'function') {
try { trapState.previouslyFocused.focus(); } catch (e) { }
}
trapState = null;
};
// ---------------------------------------------------------
// Blazor reconnect UI — replace the default overlay.
// App.razor provides #components-reconnect-modal with German
// copy; Blazor sets body[data-reconnect-state] automatically
// via the official contract (components-reconnect-show etc.).
// We just add a manual reload button handler.
// ---------------------------------------------------------
document.addEventListener('DOMContentLoaded', function () {
const reload = document.querySelector('#components-reconnect-modal .reconnect-reload');
if (reload) {
reload.addEventListener('click', function () { location.reload(); });
}
const retry = document.querySelector('#components-reconnect-modal .reconnect-retry');
if (retry) {
retry.addEventListener('click', function () {
if (window.Blazor && typeof window.Blazor.reconnect === 'function') {
window.Blazor.reconnect();
} else {
location.reload();
}
});
}
});
})();
+25
View File
@@ -0,0 +1,25 @@
# Robots.txt for Reifen Späth - www.reifenspaeth.de
# https://www.robotstxt.org/robotstxt.html
User-agent: *
# Allow public pages
Allow: /
Allow: /impressum
Allow: /datenschutz
# Disallow internal/admin pages
Disallow: /login
Disallow: /tire-storage
Disallow: /tire-checkin
Disallow: /tire-checkout
Disallow: /customers
Disallow: /storage-locations
Disallow: /users
Disallow: /status
# Allow required rendering assets so search engines can render the page correctly
Allow: /_framework/
Allow: /_content/
Sitemap: https://www.reifenspaeth.de/sitemap.xml
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- Homepage - highest priority -->
<url>
<loc>https://www.reifenspaeth.de/</loc>
<lastmod>2026-05-11</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<!-- Impressum - legal page -->
<url>
<loc>https://www.reifenspaeth.de/impressum</loc>
<lastmod>2026-05-11</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<!-- Datenschutz - legal page -->
<url>
<loc>https://www.reifenspaeth.de/datenschutz</loc>
<lastmod>2026-05-11</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more