Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -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 – bitte einen Moment warten…
|
||||
</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ängerer Inaktivitä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ö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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user