From cba9414e92c6f6aa0ad7e4e60ca2cd4e3fd418e9 Mon Sep 17 00:00:00 2001 From: spaethf Date: Mon, 21 Sep 2026 21:28:23 +0200 Subject: [PATCH] Initial commit: Lagerbestand Blazor Projekt --- .gitignore | 44 + Lagerbestand.slnx | 3 + Lagerbestand/.config/dotnet-tools.json | 13 + Lagerbestand/Components/App.razor | 245 ++ .../Components/Layout/EmptyLayout.razor | 3 + .../Components/Layout/MainLayout.razor | 653 +++++ .../Components/Layout/MainLayout.razor.css | 492 ++++ Lagerbestand/Components/Layout/NavMenu.razor | 0 Lagerbestand/Components/Pages/Customers.razor | 716 +++++ .../Components/Pages/Customers.razor.css | 738 +++++ Lagerbestand/Components/Pages/Dashboard.razor | 2396 +++++++++++++++++ .../Components/Pages/Dashboard.razor.css | 1884 +++++++++++++ .../Components/Pages/Datenschutz.razor | 156 ++ .../Components/Pages/Datenschutz.razor.css | 187 ++ Lagerbestand/Components/Pages/Error.razor | 36 + Lagerbestand/Components/Pages/Home.razor | 518 ++++ Lagerbestand/Components/Pages/Home.razor.css | 885 ++++++ Lagerbestand/Components/Pages/Impressum.razor | 111 + .../Components/Pages/Impressum.razor.css | 157 ++ Lagerbestand/Components/Pages/Login.razor | 398 +++ Lagerbestand/Components/Pages/Login.razor.css | 263 ++ Lagerbestand/Components/Pages/Status.razor | 1544 +++++++++++ .../Components/Pages/Status.razor.css | 953 +++++++ .../Components/Pages/StorageLocations.razor | 1090 ++++++++ .../Pages/StorageLocations.razor.css | 1300 +++++++++ .../Components/Pages/TireCheckin.razor | 1666 ++++++++++++ .../Components/Pages/TireCheckin.razor.css | 2239 +++++++++++++++ .../Components/Pages/TireCheckout.razor | 444 +++ .../Components/Pages/TireCheckout.razor.css | 746 +++++ .../Components/Pages/TireStorage.razor | 564 ++++ .../Components/Pages/TireStorage.razor.css | 1197 ++++++++ Lagerbestand/Components/Pages/Users.razor | 983 +++++++ Lagerbestand/Components/Pages/Users.razor.css | 682 +++++ Lagerbestand/Components/Pages/Weather.razor | 0 .../Components/Pages/Weather.razor.css | 0 Lagerbestand/Components/Routes.razor | 8 + .../Components/Shared/LocationCodeInput.razor | 451 ++++ .../Shared/LocationCodeInput.razor.css | 372 +++ .../Components/Shared/LocationDisplay.razor | 188 ++ .../Components/Shared/LocationDisplayEnums.cs | 37 + .../Components/Shared/SeoMetaTags.razor | 166 ++ .../Components/Shared/TireSizeInput.razor | 45 + .../Components/Shared/VisibilityRefresh.cs | 43 + Lagerbestand/Components/_Imports.razor | 13 + Lagerbestand/Data/AppDbContext.cs | 496 ++++ .../Exceptions/StorageLocationExceptions.cs | 87 + Lagerbestand/Lagerbestand.csproj | 14 + Lagerbestand/Models/AppUser.cs | 52 + Lagerbestand/Models/AreaType.cs | 88 + Lagerbestand/Models/CatalogItem.cs | 71 + Lagerbestand/Models/Customer.cs | 86 + Lagerbestand/Models/FileStorageSettings.cs | 48 + Lagerbestand/Models/InventoryItem.cs | 29 + Lagerbestand/Models/KanbanColumn.cs | 53 + Lagerbestand/Models/KanbanLabel.cs | 61 + Lagerbestand/Models/KanbanStatistics.cs | 32 + Lagerbestand/Models/KanbanTask.cs | 141 + Lagerbestand/Models/KanbanTaskAttachment.cs | 96 + Lagerbestand/Models/KanbanTaskComment.cs | 45 + Lagerbestand/Models/KanbanTaskNote.cs | 36 + Lagerbestand/Models/KanbanTaskRelations.cs | 57 + Lagerbestand/Models/KanbanTaskTemplate.cs | 92 + Lagerbestand/Models/KanbanTaskTodo.cs | 27 + Lagerbestand/Models/ShelfPosition.cs | 113 + Lagerbestand/Models/SiteSettings.cs | 189 ++ Lagerbestand/Models/StorageLocation.cs | 252 ++ Lagerbestand/Models/TireItem.cs | 111 + Lagerbestand/Models/TireStack.cs | 259 ++ Lagerbestand/Models/Vehicle.cs | 92 + Lagerbestand/Program.cs | 277 ++ .../PublishProfiles/FolderProfile.pubxml | 20 + Lagerbestand/Properties/launchSettings.json | 47 + Lagerbestand/Services/AuthService.cs | 477 ++++ Lagerbestand/Services/CatalogService.cs | 114 + Lagerbestand/Services/CustomerService.cs | 315 +++ Lagerbestand/Services/DatabaseInitializer.cs | 1383 ++++++++++ Lagerbestand/Services/FileStorageService.cs | 254 ++ Lagerbestand/Services/IFileStorageService.cs | 49 + Lagerbestand/Services/IKanbanService.cs | 98 + Lagerbestand/Services/InventoryService.cs | 189 ++ Lagerbestand/Services/KanbanService.cs | 1244 +++++++++ Lagerbestand/Services/LexwareSyncService.cs | 515 ++++ Lagerbestand/Services/LocationCodeService.cs | 275 ++ Lagerbestand/Services/SiteSettingsService.cs | 452 ++++ .../Services/StorageLocationService.cs | 447 +++ Lagerbestand/Services/TireStackService.cs | 504 ++++ Lagerbestand/Services/TwoFactorService.cs | 243 ++ Lagerbestand/Services/UserSessionService.cs | 113 + Lagerbestand/appsettings.Development.json | 16 + Lagerbestand/appsettings.Production.json | 18 + Lagerbestand/appsettings.json | 17 + Lagerbestand/wwwroot/app.css | 1484 ++++++++++ .../wwwroot/bootstrap/bootstrap.min.css | 7 + .../wwwroot/bootstrap/bootstrap.min.css.map | 1 + Lagerbestand/wwwroot/css/app-interop.css | 217 ++ .../wwwroot/css/bootstrap/bootstrap.min.css | 7 + .../css/bootstrap/bootstrap.min.css.map | 1 + Lagerbestand/wwwroot/favicon.png | Bin 0 -> 83057 bytes .../wwwroot/google6d5a1b5c60a38816.html | 1 + Lagerbestand/wwwroot/js/app-interop.js | 334 +++ Lagerbestand/wwwroot/robots.txt | 25 + Lagerbestand/wwwroot/sitemap.xml | 31 + .../587ffbc9-bf0b-495e-9460-fc2acdfff0c1.jpg | Bin 0 -> 29541 bytes .../b4fc7203-f064-42b9-b0b7-3d03cf9b60e3.jpg | Bin 0 -> 29541 bytes .../cf552b3b-6fdf-46d1-ab07-6ca3642f3587.jpg | Bin 0 -> 29541 bytes 105 files changed, 36431 insertions(+) create mode 100644 .gitignore create mode 100644 Lagerbestand.slnx create mode 100644 Lagerbestand/.config/dotnet-tools.json create mode 100644 Lagerbestand/Components/App.razor create mode 100644 Lagerbestand/Components/Layout/EmptyLayout.razor create mode 100644 Lagerbestand/Components/Layout/MainLayout.razor create mode 100644 Lagerbestand/Components/Layout/MainLayout.razor.css create mode 100644 Lagerbestand/Components/Layout/NavMenu.razor create mode 100644 Lagerbestand/Components/Pages/Customers.razor create mode 100644 Lagerbestand/Components/Pages/Customers.razor.css create mode 100644 Lagerbestand/Components/Pages/Dashboard.razor create mode 100644 Lagerbestand/Components/Pages/Dashboard.razor.css create mode 100644 Lagerbestand/Components/Pages/Datenschutz.razor create mode 100644 Lagerbestand/Components/Pages/Datenschutz.razor.css create mode 100644 Lagerbestand/Components/Pages/Error.razor create mode 100644 Lagerbestand/Components/Pages/Home.razor create mode 100644 Lagerbestand/Components/Pages/Home.razor.css create mode 100644 Lagerbestand/Components/Pages/Impressum.razor create mode 100644 Lagerbestand/Components/Pages/Impressum.razor.css create mode 100644 Lagerbestand/Components/Pages/Login.razor create mode 100644 Lagerbestand/Components/Pages/Login.razor.css create mode 100644 Lagerbestand/Components/Pages/Status.razor create mode 100644 Lagerbestand/Components/Pages/Status.razor.css create mode 100644 Lagerbestand/Components/Pages/StorageLocations.razor create mode 100644 Lagerbestand/Components/Pages/StorageLocations.razor.css create mode 100644 Lagerbestand/Components/Pages/TireCheckin.razor create mode 100644 Lagerbestand/Components/Pages/TireCheckin.razor.css create mode 100644 Lagerbestand/Components/Pages/TireCheckout.razor create mode 100644 Lagerbestand/Components/Pages/TireCheckout.razor.css create mode 100644 Lagerbestand/Components/Pages/TireStorage.razor create mode 100644 Lagerbestand/Components/Pages/TireStorage.razor.css create mode 100644 Lagerbestand/Components/Pages/Users.razor create mode 100644 Lagerbestand/Components/Pages/Users.razor.css create mode 100644 Lagerbestand/Components/Pages/Weather.razor create mode 100644 Lagerbestand/Components/Pages/Weather.razor.css create mode 100644 Lagerbestand/Components/Routes.razor create mode 100644 Lagerbestand/Components/Shared/LocationCodeInput.razor create mode 100644 Lagerbestand/Components/Shared/LocationCodeInput.razor.css create mode 100644 Lagerbestand/Components/Shared/LocationDisplay.razor create mode 100644 Lagerbestand/Components/Shared/LocationDisplayEnums.cs create mode 100644 Lagerbestand/Components/Shared/SeoMetaTags.razor create mode 100644 Lagerbestand/Components/Shared/TireSizeInput.razor create mode 100644 Lagerbestand/Components/Shared/VisibilityRefresh.cs create mode 100644 Lagerbestand/Components/_Imports.razor create mode 100644 Lagerbestand/Data/AppDbContext.cs create mode 100644 Lagerbestand/Exceptions/StorageLocationExceptions.cs create mode 100644 Lagerbestand/Lagerbestand.csproj create mode 100644 Lagerbestand/Models/AppUser.cs create mode 100644 Lagerbestand/Models/AreaType.cs create mode 100644 Lagerbestand/Models/CatalogItem.cs create mode 100644 Lagerbestand/Models/Customer.cs create mode 100644 Lagerbestand/Models/FileStorageSettings.cs create mode 100644 Lagerbestand/Models/InventoryItem.cs create mode 100644 Lagerbestand/Models/KanbanColumn.cs create mode 100644 Lagerbestand/Models/KanbanLabel.cs create mode 100644 Lagerbestand/Models/KanbanStatistics.cs create mode 100644 Lagerbestand/Models/KanbanTask.cs create mode 100644 Lagerbestand/Models/KanbanTaskAttachment.cs create mode 100644 Lagerbestand/Models/KanbanTaskComment.cs create mode 100644 Lagerbestand/Models/KanbanTaskNote.cs create mode 100644 Lagerbestand/Models/KanbanTaskRelations.cs create mode 100644 Lagerbestand/Models/KanbanTaskTemplate.cs create mode 100644 Lagerbestand/Models/KanbanTaskTodo.cs create mode 100644 Lagerbestand/Models/ShelfPosition.cs create mode 100644 Lagerbestand/Models/SiteSettings.cs create mode 100644 Lagerbestand/Models/StorageLocation.cs create mode 100644 Lagerbestand/Models/TireItem.cs create mode 100644 Lagerbestand/Models/TireStack.cs create mode 100644 Lagerbestand/Models/Vehicle.cs create mode 100644 Lagerbestand/Program.cs create mode 100644 Lagerbestand/Properties/PublishProfiles/FolderProfile.pubxml create mode 100644 Lagerbestand/Properties/launchSettings.json create mode 100644 Lagerbestand/Services/AuthService.cs create mode 100644 Lagerbestand/Services/CatalogService.cs create mode 100644 Lagerbestand/Services/CustomerService.cs create mode 100644 Lagerbestand/Services/DatabaseInitializer.cs create mode 100644 Lagerbestand/Services/FileStorageService.cs create mode 100644 Lagerbestand/Services/IFileStorageService.cs create mode 100644 Lagerbestand/Services/IKanbanService.cs create mode 100644 Lagerbestand/Services/InventoryService.cs create mode 100644 Lagerbestand/Services/KanbanService.cs create mode 100644 Lagerbestand/Services/LexwareSyncService.cs create mode 100644 Lagerbestand/Services/LocationCodeService.cs create mode 100644 Lagerbestand/Services/SiteSettingsService.cs create mode 100644 Lagerbestand/Services/StorageLocationService.cs create mode 100644 Lagerbestand/Services/TireStackService.cs create mode 100644 Lagerbestand/Services/TwoFactorService.cs create mode 100644 Lagerbestand/Services/UserSessionService.cs create mode 100644 Lagerbestand/appsettings.Development.json create mode 100644 Lagerbestand/appsettings.Production.json create mode 100644 Lagerbestand/appsettings.json create mode 100644 Lagerbestand/wwwroot/app.css create mode 100644 Lagerbestand/wwwroot/bootstrap/bootstrap.min.css create mode 100644 Lagerbestand/wwwroot/bootstrap/bootstrap.min.css.map create mode 100644 Lagerbestand/wwwroot/css/app-interop.css create mode 100644 Lagerbestand/wwwroot/css/bootstrap/bootstrap.min.css create mode 100644 Lagerbestand/wwwroot/css/bootstrap/bootstrap.min.css.map create mode 100644 Lagerbestand/wwwroot/favicon.png create mode 100644 Lagerbestand/wwwroot/google6d5a1b5c60a38816.html create mode 100644 Lagerbestand/wwwroot/js/app-interop.js create mode 100644 Lagerbestand/wwwroot/robots.txt create mode 100644 Lagerbestand/wwwroot/sitemap.xml create mode 100644 Lagerbestand/wwwroot/uploads/images/587ffbc9-bf0b-495e-9460-fc2acdfff0c1.jpg create mode 100644 Lagerbestand/wwwroot/uploads/images/b4fc7203-f064-42b9-b0b7-3d03cf9b60e3.jpg create mode 100644 Lagerbestand/wwwroot/uploads/images/cf552b3b-6fdf-46d1-ab07-6ca3642f3587.jpg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b17858 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio cache/options directory +.vs/ + +# Visual Studio Code +.vscode/ +*.code-workspace + +# Rider IDE +.idea/ +*.sln.iml + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# JetBrains Rider +.idea/ +*.sln.iml + +# Local environment variables +.env +.env.local + +# Node modules (if using npm/yarn for frontend) +node_modules/ diff --git a/Lagerbestand.slnx b/Lagerbestand.slnx new file mode 100644 index 0000000..5d2dace --- /dev/null +++ b/Lagerbestand.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Lagerbestand/.config/dotnet-tools.json b/Lagerbestand/.config/dotnet-tools.json new file mode 100644 index 0000000..fbb7b76 --- /dev/null +++ b/Lagerbestand/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.1", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/Lagerbestand/Components/App.razor b/Lagerbestand/Components/App.razor new file mode 100644 index 0000000..9bf09c2 --- /dev/null +++ b/Lagerbestand/Components/App.razor @@ -0,0 +1,245 @@ + + + + + + + + @* Primary SEO Meta Tags *@ + + + + + + + + @* Theme & Mobile Meta Tags *@ + + + + + + + + + + + + @* Open Graph Default Meta Tags *@ + + + + + @* Twitter Card Defaults *@ + + + @* Geo Meta Tags for Local SEO *@ + + + + + + + + @* Preconnect for critical resources (performance) *@ + + + + @* DNS Prefetch for external resources *@ + + + @* Preload critical CSS for faster LCP *@ + + + @* Fonts with display=swap for better CLS *@ + + + @* Stylesheets *@ + + + + + + @* Favicon & Icons for all platforms - cache busted *@ + + + + + + + + + + @* Structured Data - Organization Schema *@ + + + @* Structured Data - Website Schema for sitelinks searchbox *@ + + + @* Analytics - defer for non-blocking *@ + + + + + + + @* Skip to main content link for accessibility *@ + + + + + @* Custom Blazor Server reconnect overlay (replaces default) *@ +
+
+ +

Verbindung wird wiederhergestellt

+

+ Die Verbindung zum Server wurde kurzzeitig unterbrochen. + Ihre Eingaben bleiben erhalten – bitte einen Moment warten… +

+

+ Die Verbindung zum Server konnte nicht wiederhergestellt werden. + Bitte laden Sie die Seite neu. +

+

+ Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu, + um weiterzuarbeiten. +

+
+ + +
+
+
+ + @* Idle-lock overlay (JS-driven via appInterop.startIdleWatcher) *@ + + + @* Blazor Framework - defer loading *@ + + + + + + +@code { + private static readonly string CacheVersion = DateTime.UtcNow.Ticks.ToString(); +} diff --git a/Lagerbestand/Components/Layout/EmptyLayout.razor b/Lagerbestand/Components/Layout/EmptyLayout.razor new file mode 100644 index 0000000..4f3f76d --- /dev/null +++ b/Lagerbestand/Components/Layout/EmptyLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/Lagerbestand/Components/Layout/MainLayout.razor b/Lagerbestand/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..126fa3d --- /dev/null +++ b/Lagerbestand/Components/Layout/MainLayout.razor @@ -0,0 +1,653 @@ +@inherits LayoutComponentBase +@implements IDisposable +@inject UserSessionService UserSession +@inject IAuthService AuthService +@inject NavigationManager Navigation +@inject IJSRuntime JS + +@if (IsPublicPage()) +{ +
+ @Body +
+} +else +{ + @switch (state) + { + case AuthState.Loading: +
+ +

Lade...

+
+ break; + + case AuthState.Authenticated: +
+ + +
+ + + @Body + + + + + +
+
+ break; + + case AuthState.NotAuthenticated: +
+ +

Anmeldung erforderlich

+

Sie müssen angemeldet sein, um auf diese Seite zuzugreifen.

+ Zum Login +
+ break; + } +} + +@* Logout Modal *@ +@if (showLogoutModal) +{ + + +} + +@* Password Change Modal *@ +@if (showPasswordModal) +{ + + +} + +@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? 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 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("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(); + } +} diff --git a/Lagerbestand/Components/Layout/MainLayout.razor.css b/Lagerbestand/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..f8de8b8 --- /dev/null +++ b/Lagerbestand/Components/Layout/MainLayout.razor.css @@ -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) */ diff --git a/Lagerbestand/Components/Layout/NavMenu.razor b/Lagerbestand/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..e69de29 diff --git a/Lagerbestand/Components/Pages/Customers.razor b/Lagerbestand/Components/Pages/Customers.razor new file mode 100644 index 0000000..834ac42 --- /dev/null +++ b/Lagerbestand/Components/Pages/Customers.razor @@ -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 + +Kundenverwaltung - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsAdmin) +{ + +} +else +{ +
+ + + @if (!string.IsNullOrEmpty(syncMessage)) + { +
@syncMessage
+ } + + @* Search *@ + + + @* Customer List *@ + @if (isLoading) + { +
+ + Lade Kunden... +
+ } + else if (filteredCustomers.Count == 0) + { +
+ +

Keine Kunden

+

@(string.IsNullOrEmpty(searchTerm) ? "Noch keine Kunden angelegt." : "Keine Ergebnisse für Ihre Suche.")

+
+ } + else + { +
+ @foreach (var customer in filteredCustomers) + { +
+
+ +
+ @customer.FullName + @if (!string.IsNullOrEmpty(customer.Company)) + { + @customer.Company + } + @customer.CustomerNumber +
+
+ @{ + var activeVehicles = customer.Vehicles?.Where(v => v.IsActive).ToList() ?? new List(); + } + @if (activeVehicles.Any()) + { + @string.Join(", ", activeVehicles.Select(v => v.ShortName)) + } + + @(customer.TireStacks?.Count(ts => ts.Status == TireStackStatus.Stored) ?? 0) Stapel + +
+
+ + @if (expandedId == customer.Id) + { +
+
+ @if (!string.IsNullOrEmpty(customer.Phone)) + { +
+
Telefon
+
@customer.Phone
+
+ } + @if (!string.IsNullOrEmpty(customer.Email)) + { +
+
E-Mail
+
@customer.Email
+
+ } + @if (customer.PrimaryVehicle is { } primaryVehicle) + { +
+
Fahrzeug
+
@primaryVehicle.DisplayName
+
+ } +
+
Kunde seit
+
+
+
+ + @if (!string.IsNullOrEmpty(customer.Notes)) + { +
+ Notizen + @customer.Notes +
+ } + + @* Customer's Tire Stacks *@ + @if (customerTireStacks.TryGetValue(customer.Id, out var tireStacks) && tireStacks.Count > 0) + { +
+

Eingelagerte Reifenstapel

+
    + @foreach (var ts in tireStacks.Where(t => t.Status == TireStackStatus.Stored)) + { +
  • + + @(ts.Season == TireSeason.Sommer ? "☀ï¸�" : ts.Season == TireSeason.Winter ? "â�„ï¸�" : "🔄") + + @ts.TireSize (@TireStack.TiresPerStack Reifen) + @(ts.StorageLocation?.Code ?? "-") +
  • + } +
+
+ } + + +
+ } +
+ } +
+ } +
+ +@* Add/Edit Customer Modal *@ +@if (showModal) +{ + + +} +} + +@code { + private List allCustomers = new(); + private List filteredCustomers = new(); + private Dictionary> 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 editVehicles = new(); + private Vehicle editVehicle = new(); + private bool showVehicleForm; + private bool isEditingVehicle; + private List 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? _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(); + vehiclesToDelete = new List(); + 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(); + isEditing = true; + errorMessage = null; + showVehicleForm = false; + showModal = true; + } + + private void CloseModal() + { + showModal = false; + editCustomer = new Customer(); + editVehicles = new List(); + vehiclesToDelete = new List(); + 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}"; + } + } +} diff --git a/Lagerbestand/Components/Pages/Customers.razor.css b/Lagerbestand/Components/Pages/Customers.razor.css new file mode 100644 index 0000000..13d8900 --- /dev/null +++ b/Lagerbestand/Components/Pages/Customers.razor.css @@ -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) */ diff --git a/Lagerbestand/Components/Pages/Dashboard.razor b/Lagerbestand/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..77901c5 --- /dev/null +++ b/Lagerbestand/Components/Pages/Dashboard.razor @@ -0,0 +1,2396 @@ +@page "/dashboard" +@implements IAsyncDisposable +@inject IKanbanService KanbanService +@inject ICustomerService CustomerService +@inject ITireStackService TireStackService +@inject IAuthService AuthService +@inject UserSessionService UserSession +@inject IJSRuntime JS +@using Lagerbestand.Components.Shared + +Dashboard - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsWorkerOrAdmin) +{ + +} +else +{ +
+ + + @* Error Display *@ + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + @* Statistics *@ +
+
+ @statistics.TotalTasks + Offene Aufgaben +
+
+ @statistics.TasksDueToday + Heute fällig +
+
+ @statistics.OverdueTasks + Überfällig +
+
+ @statistics.CompletedThisWeek + Diese Woche erledigt +
+
+ + @* Search & Filter Bar *@ +
+ +
+
+ + @if (showLabelFilter) + { +
+ + + @foreach (var label in GetFilteredLabels()) + { + + } +
+ } +
+
+ + @if (showCustomerFilter) + { +
+ + + @foreach (var customer in GetFilteredFilterCustomers()) + { + + } +
+ } +
+ @if (HasActiveFilters) + { + + } +
+
+ + @if (isLoading) + { +
+ + Lade Dashboard... +
+ } + else if (columns.Count == 0) + { +
+ +

Keine Spalten vorhanden

+

Das Kanban-Board wurde noch nicht initialisiert.

+ @if (UserSession.IsAdmin) + { + + } +
+ } + else + { + @* Kanban Board *@ +
+ @foreach (var column in columns) + { + var columnTasks = GetTasksForColumn(column); +
+
+
+ @if (!string.IsNullOrEmpty(column.Icon)) + { + + } +

@column.Name

+ @columnTasks.Count +
+ +
+
+ @foreach (var task in columnTasks) + { +
+ @RenderTaskCard(task) +
+ } +
+
+ } +
+ } +
+ +@* Create/Edit Task Modal *@ +@if (showTaskModal && editingTask != null) +{ + + +} + +@* Task Detail Modal *@ +@if (showTaskDetailModal && selectedTask != null) +{ + + +} + +@* Templates Modal *@ +@if (showTemplateModal) +{ + + +} + +@* Labels Management Modal (Admin only) *@ +@if (showLabelsModal && UserSession.IsAdmin) +{ + + +} +} + +@code { + // === State === + private bool isLoading = true; + private bool isSaving; + private bool isUploading; + private string? errorMessage; + + // === Data === + private List columns = new(); + private List tasks = new(); + private List labels = new(); + private List templates = new(); + private List customers = new(); + private List availableUsers = new(); + private KanbanStatistics statistics = new(); + + // === Drag & Drop === + private KanbanTask? draggedTask; + private Guid? dragOverColumnId; + + // === Task Modal === + private bool showTaskModal; + private KanbanTask? editingTask; + private bool isEditingExistingTask; + private List selectedLabelIds = new(); + private List selectedAssigneeIds = new(); + private List selectedCustomerIds = new(); + private Dictionary selectedVehicleIds = new(); + private List selectedTireStackIds = new(); + private List availableTireStacks = new(); + private KanbanTaskTemplate? activeTemplate; + private List pendingTemplateTodos = new(); + private string? templateTitlePattern; + private string? templateDescriptionPattern; + + // === Task Detail Modal === + private bool showTaskDetailModal; + private KanbanTask? selectedTask; + private ElementReference chatContainer; + private string newChatMessage = ""; + private string newTodoText = ""; + private Guid? editingTodoId; + private string editingTodoText = ""; + private string taskNotesText = ""; + private string taskTireSizeNote = ""; + + // === Templates Modal === + private bool showTemplateModal; + private bool showTemplateForm; + private KanbanTaskTemplate? editingTemplate; + private List editingTemplateTodos = new(); + private List selectedTemplateLabelIds = new(); + private string newTemplateTodoText = ""; + + // === Labels Modal === + private bool showLabelsModal; + private string newLabelName = ""; + private string newLabelColor = "#3b82f6"; + + // === Customer Search === + private string customerSearchText = ""; + + // === Board Search & Filter === + private string boardSearchText = ""; + private Guid? filterLabelId; + private Guid? filterCustomerId; + private bool showLabelFilter; + private bool showCustomerFilter; + private string filterLabelSearch = ""; + private string filterCustomerSearch = ""; + private List recentlyArchivedTasks = new(); + private bool confirmDeleteTask; + private bool HasActiveFilters => !string.IsNullOrWhiteSpace(boardSearchText) || filterLabelId.HasValue || filterCustomerId.HasValue; + + protected override async Task OnInitializedAsync() + { + if (UserSession.IsWorkerOrAdmin) + { + await LoadDataSafe(); + } + } + + private DotNetObjectReference? _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 LoadDataSafe(); + await InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() + { + await VisibilityRefresh.UnregisterAsync(JS); + _visibilityRef?.Dispose(); + } + + private async Task LoadDataSafe() + { + isLoading = true; + errorMessage = null; + StateHasChanged(); + + try + { + columns = await KanbanService.GetColumnsAsync(); + tasks = await KanbanService.GetTasksAsync(); + labels = await KanbanService.GetLabelsAsync(); + templates = await KanbanService.GetTemplatesAsync(); + customers = await CustomerService.GetAllAsync(); + availableUsers = await AuthService.GetAllUsersAsync(); + statistics = await KanbanService.GetStatisticsAsync(); + recentlyArchivedTasks = await KanbanService.GetRecentlyArchivedTasksAsync(7); + + // Auto-archive old completed tasks (run sequentially to avoid DbContext concurrency) + try + { + await KanbanService.ArchiveOldCompletedTasksAsync(30); + } + catch + { + // Logged in service, non-critical + } + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Laden: {ex.Message}"; + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + private async Task RefreshData() + { + try + { + tasks = await KanbanService.GetTasksAsync(); + recentlyArchivedTasks = await KanbanService.GetRecentlyArchivedTasksAsync(7); + statistics = await KanbanService.GetStatisticsAsync(); + StateHasChanged(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Aktualisieren: {ex.Message}"; + } + } + + private List GetTasksForColumn(KanbanColumn column) + { + var columnTasks = tasks.Where(t => t.ColumnId == column.Id).AsEnumerable(); + + // Text search + if (!string.IsNullOrWhiteSpace(boardSearchText)) + { + var search = boardSearchText.Trim(); + columnTasks = columnTasks.Where(t => + (t.Title?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) || + (t.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true)); + } + + // Label filter + if (filterLabelId.HasValue) + { + columnTasks = columnTasks.Where(t => + t.TaskLabels?.Any(tl => tl.LabelId == filterLabelId.Value) == true); + } + + // Customer filter + if (filterCustomerId.HasValue) + { + columnTasks = columnTasks.Where(t => + t.TaskCustomers?.Any(tc => tc.CustomerId == filterCustomerId.Value) == true); + } + + if (column.IsCompletedColumn) + { + // Include recently archived tasks (≤7 days) in the completed column for easy restore + var archivedForColumn = recentlyArchivedTasks.AsEnumerable(); + + // Apply same filters to archived tasks + if (!string.IsNullOrWhiteSpace(boardSearchText)) + { + var search = boardSearchText.Trim(); + archivedForColumn = archivedForColumn.Where(t => + (t.Title?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) || + (t.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true)); + } + if (filterLabelId.HasValue) + { + archivedForColumn = archivedForColumn.Where(t => + t.TaskLabels?.Any(tl => tl.LabelId == filterLabelId.Value) == true); + } + if (filterCustomerId.HasValue) + { + archivedForColumn = archivedForColumn.Where(t => + t.TaskCustomers?.Any(tc => tc.CustomerId == filterCustomerId.Value) == true); + } + + return columnTasks + .Concat(archivedForColumn) + .OrderByDescending(t => t.CompletedAt ?? t.UpdatedAt) + .ToList(); + } + + return columnTasks.OrderBy(t => t.SortOrder).ToList(); + } + + private string GetColumnHeaderStyle(KanbanColumn column) + { + if (!string.IsNullOrEmpty(column.Color)) + { + return $"border-top-color: {column.Color}"; + } + return ""; + } + + private async Task InitializeDefaultColumns() + { + try + { + // Manually create default columns if they don't exist + var defaultColumns = new[] + { + ("📥", "Backlog", "#6b7280", true, false), + ("📋", "ToDo", "#3b82f6", false, false), + ("🔄", "In Arbeit", "#f59e0b", false, false), + ("�", "Warte auf Kunden", "#8b5cf6", false, false), + ("✅", "Erledigt", "#10b981", false, true) + }; + + foreach (var (icon, name, color, isDefault, isCompleted) in defaultColumns) + { + await KanbanService.CreateColumnAsync(new KanbanColumn + { + Name = name, + Icon = icon, + Color = color, + IsDefault = isDefault, + IsCompletedColumn = isCompleted + }); + } + + await LoadDataSafe(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Erstellen der Spalten: {ex.Message}"; + } + } + + #region Task Card Rendering + + private RenderFragment RenderTaskCard(KanbanTask task) => __builder => + { +
+
+ + @if (task.IsArchived) + { + 📦 + } + @task.Title + +
+ +
+
+ @if (task.TaskLabels?.Any() == true) + { +
+ @foreach (var tl in task.TaskLabels.Take(3)) + { + @if (tl.Label != null) + { + + } + } + @if (task.TaskLabels.Count > 3) + { + +@(task.TaskLabels.Count - 3) + } +
+ } + +
+ }; + + private string GetTaskCardClasses(KanbanTask task) + { + var classes = new List(); + + if (task.IsArchived) classes.Add("archived"); + else if (task.IsOverdue) classes.Add("overdue"); + else if (task.IsDueToday) classes.Add("due-today"); + + classes.Add($"priority-{task.Priority.ToString().ToLower()}"); + + if (draggedTask?.Id == task.Id) classes.Add("dragging"); + + return string.Join(" ", classes); + } + + private string FormatEstimatedTime(int? minutes) + { + if (!minutes.HasValue || minutes.Value <= 0) return ""; + if (minutes.Value < 60) return $"{minutes.Value}m"; + var hours = minutes.Value / 60; + var mins = minutes.Value % 60; + return mins > 0 ? $"{hours}h {mins}m" : $"{hours}h"; + } + + #endregion + + #region Drag & Drop + + private void HandleDragStart(KanbanTask task) + { + draggedTask = task; + } + + private void HandleDragEnd() + { + draggedTask = null; + dragOverColumnId = null; + StateHasChanged(); + } + + private void HandleDragOver() + { + // Required for drop to work + } + + private void HandleDragEnter(Guid columnId) + { + if (draggedTask != null) + { + dragOverColumnId = columnId; + } + } + + private void HandleDragLeave() + { + // Don't clear immediately to avoid flickering + } + + private async Task HandleDrop(Guid columnId) + { + if (draggedTask != null && draggedTask.ColumnId != columnId) + { + try + { + await KanbanService.MoveTaskAsync(draggedTask.Id, columnId, 0, UserSession.UserId); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Verschieben: {ex.Message}"; + } + } + + draggedTask = null; + dragOverColumnId = null; + } + + private async Task OnTaskColumnChange(Guid taskId, ChangeEventArgs e) + { + if (Guid.TryParse(e.Value?.ToString(), out var newColumnId)) + { + try + { + await KanbanService.MoveTaskAsync(taskId, newColumnId, 0, UserSession.UserId); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Verschieben: {ex.Message}"; + } + } + } + + #endregion + + #region Task Modal + + private void OpenCreateTaskModal(Guid? columnId) + { + var defaultColumnId = columnId ?? columns.FirstOrDefault(c => c.IsDefault)?.Id ?? columns.FirstOrDefault()?.Id ?? Guid.Empty; + + editingTask = new KanbanTask + { + ColumnId = defaultColumnId, + Priority = KanbanTaskPriority.Medium + }; + + isEditingExistingTask = false; + selectedLabelIds.Clear(); + selectedAssigneeIds.Clear(); + selectedCustomerIds.Clear(); + selectedVehicleIds.Clear(); + selectedTireStackIds.Clear(); + availableTireStacks.Clear(); + customerSearchText = ""; + showTaskModal = true; + } + + private void CloseTaskModal() + { + showTaskModal = false; + editingTask = null; + isEditingExistingTask = false; + activeTemplate = null; + pendingTemplateTodos.Clear(); + templateTitlePattern = null; + templateDescriptionPattern = null; + customerSearchText = ""; + selectedVehicleIds.Clear(); + } + + private async Task EditSelectedTask() + { + if (selectedTask == null) return; + + // Copy task data for editing + editingTask = new KanbanTask + { + Id = selectedTask.Id, + Title = selectedTask.Title, + Description = selectedTask.Description, + ColumnId = selectedTask.ColumnId, + Priority = selectedTask.Priority, + DueDate = selectedTask.DueDate, + EstimatedMinutes = selectedTask.EstimatedMinutes, + SortOrder = selectedTask.SortOrder, + NotesText = selectedTask.NotesText, + TireSizeNote = selectedTask.TireSizeNote + }; + + // Populate selected labels + selectedLabelIds = selectedTask.TaskLabels?.Select(tl => tl.LabelId).ToList() ?? new List(); + + // Populate selected assignees + selectedAssigneeIds = selectedTask.Assignees?.Select(a => a.UserId).ToList() ?? new List(); + + // Populate selected customers + selectedCustomerIds = selectedTask.TaskCustomers?.Select(tc => tc.CustomerId).ToList() ?? new List(); + + // Clear vehicle selections (user re-picks vehicles when editing) + selectedVehicleIds.Clear(); + + // Load tire stacks for selected customers + availableTireStacks.Clear(); + await LoadTireStacksForCustomers(); + + // Populate selected tire stacks + selectedTireStackIds = selectedTask.TaskTireStacks?.Select(ts => ts.TireStackId).ToList() ?? new List(); + + isEditingExistingTask = true; + showTaskDetailModal = false; + showTaskModal = true; + } + + private async Task SaveTask() + { + if (editingTask == null || string.IsNullOrWhiteSpace(editingTask.Title)) + { + errorMessage = "Bitte geben Sie einen Titel ein."; + return; + } + + isSaving = true; + StateHasChanged(); + + try + { + KanbanTask savedTask; + + if (isEditingExistingTask && editingTask.Id != Guid.Empty) + { + savedTask = await KanbanService.UpdateTaskAsync(editingTask, UserSession.UserId); + } + else + { + savedTask = await KanbanService.CreateTaskAsync(editingTask, UserSession.UserId); + } + + // Update relations + await KanbanService.SetTaskLabelsAsync(savedTask.Id, selectedLabelIds); + await KanbanService.SetAssigneesAsync(savedTask.Id, selectedAssigneeIds, UserSession.UserId); + var customerVehicleMap = selectedCustomerIds.ToDictionary( + cid => cid, + cid => selectedVehicleIds.TryGetValue(cid, out var vid) ? vid : null); + await KanbanService.SetTaskCustomersAsync(savedTask.Id, customerVehicleMap); + await KanbanService.SetTaskTireStacksAsync(savedTask.Id, selectedTireStackIds); + + // Add template todos if creating from template + if (!isEditingExistingTask && pendingTemplateTodos.Count > 0) + { + foreach (var todoText in pendingTemplateTodos) + { + await KanbanService.AddTodoAsync(savedTask.Id, todoText); + } + } + + CloseTaskModal(); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Speichern: {ex.Message}"; + } + finally + { + isSaving = false; + StateHasChanged(); + } + } + + private void ToggleLabel(Guid labelId, bool selected) + { + if (selected && !selectedLabelIds.Contains(labelId)) + selectedLabelIds.Add(labelId); + else if (!selected) + selectedLabelIds.Remove(labelId); + } + + private void ToggleAssignee(Guid userId, bool selected) + { + if (selected && !selectedAssigneeIds.Contains(userId)) + selectedAssigneeIds.Add(userId); + else if (!selected) + selectedAssigneeIds.Remove(userId); + } + + private async Task OnCustomerSelected(ChangeEventArgs e) + { + if (Guid.TryParse(e.Value?.ToString(), out var customerId) && !selectedCustomerIds.Contains(customerId)) + { + await SelectCustomerById(customerId); + } + } + + private List GetFilteredCustomers() + { + var available = customers.Where(c => !selectedCustomerIds.Contains(c.Id)); + if (!string.IsNullOrWhiteSpace(customerSearchText)) + { + var search = customerSearchText.Trim().ToLower(); + available = available.Where(c => + (c.FullName?.ToLower().Contains(search) == true) || + (c.CustomerNumber?.ToLower().Contains(search) == true) || + (c.Company?.ToLower().Contains(search) == true) || + (c.Email?.ToLower().Contains(search) == true) || + (c.Phone?.ToLower().Contains(search) == true)); + } + return available.Take(10).ToList(); + } + + private async Task SelectCustomerFromSearch(Guid customerId) + { + customerSearchText = ""; + await SelectCustomerById(customerId); + } + + private async Task SelectCustomerById(Guid customerId) + { + if (!selectedCustomerIds.Contains(customerId)) + { + selectedCustomerIds.Add(customerId); + await LoadTireStacksForCustomers(); + + // Auto-select vehicle if customer has exactly 1 active vehicle + var customer = customers.FirstOrDefault(c => c.Id == customerId); + if (customer != null) + { + var activeVehicles = customer.Vehicles?.Where(v => v.IsActive).ToList() ?? new(); + if (activeVehicles.Count == 1) + { + selectedVehicleIds[customerId] = activeVehicles[0].Id; + AutoFillTireSizeFromVehicle(activeVehicles[0]); + } + } + + // Resolve template placeholders when first customer is selected + if (activeTemplate != null && editingTask != null && selectedCustomerIds.Count == 1) + { + if (customer != null) + { + var selectedVehicle = selectedVehicleIds.TryGetValue(customerId, out var vid) && vid.HasValue + ? customer.Vehicles?.FirstOrDefault(v => v.Id == vid.Value) + : null; + editingTask.Title = ResolveClientPlaceholders(templateTitlePattern, customer, selectedVehicle); + editingTask.Description = ResolveClientPlaceholders(templateDescriptionPattern, customer, selectedVehicle); + pendingTemplateTodos = pendingTemplateTodos + .Select(text => ResolveClientPlaceholders(text, customer, selectedVehicle)) + .ToList(); + } + } + } + } + + private async Task RemoveCustomer(Guid customerId) + { + selectedCustomerIds.Remove(customerId); + selectedVehicleIds.Remove(customerId); + selectedTireStackIds.RemoveAll(id => availableTireStacks.Any(ts => ts.Id == id && ts.CustomerId == customerId)); + await LoadTireStacksForCustomers(); + } + + private void OnVehicleSelected(Guid customerId, Guid? vehicleId) + { + if (vehicleId.HasValue && vehicleId.Value != Guid.Empty) + { + selectedVehicleIds[customerId] = vehicleId.Value; + var customer = customers.FirstOrDefault(c => c.Id == customerId); + var vehicle = customer?.Vehicles?.FirstOrDefault(v => v.Id == vehicleId.Value); + if (vehicle != null) + { + AutoFillTireSizeFromVehicle(vehicle); + } + } + else + { + selectedVehicleIds.Remove(customerId); + } + } + + private void AutoFillTireSizeFromVehicle(Vehicle vehicle) + { + if (editingTask != null && string.IsNullOrWhiteSpace(editingTask.TireSizeNote) && !string.IsNullOrWhiteSpace(vehicle.TireSize)) + { + editingTask.TireSizeNote = vehicle.TireSize; + } + } + + private async Task LoadTireStacksForCustomers() + { + availableTireStacks.Clear(); + foreach (var customerId in selectedCustomerIds) + { + try + { + var stacks = await TireStackService.GetByCustomerAsync(customerId); + availableTireStacks.AddRange(stacks); + } + catch { /* ignore errors loading tire stacks */ } + } + StateHasChanged(); + } + + private void ToggleTireStack(Guid tireStackId, bool selected) + { + if (selected && !selectedTireStackIds.Contains(tireStackId)) + selectedTireStackIds.Add(tireStackId); + else if (!selected) + selectedTireStackIds.Remove(tireStackId); + } + + #endregion + + #region Task Detail Modal + + private async Task ShowTaskDetail(KanbanTask task) + { + try + { + selectedTask = await KanbanService.GetTaskByIdAsync(task.Id); + if (selectedTask != null) + { + taskNotesText = selectedTask.NotesText ?? ""; + taskTireSizeNote = selectedTask.TireSizeNote ?? ""; + showTaskDetailModal = true; + await ScrollChatToBottom(); + } + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Laden der Aufgabe: {ex.Message}"; + } + } + + private void CloseTaskDetailModal() + { + showTaskDetailModal = false; + selectedTask = null; + confirmDeleteTask = false; + ResetDetailInputs(); + } + + private void ResetDetailInputs() + { + newChatMessage = ""; + newTodoText = ""; + editingTodoId = null; + editingTodoText = ""; + taskNotesText = ""; + taskTireSizeNote = ""; + } + + private async Task ScrollChatToBottom() + { + await Task.Delay(100); + try + { + await JS.InvokeVoidAsync("eval", "document.querySelector('.chat-messages')?.scrollTo(0, document.querySelector('.chat-messages')?.scrollHeight || 0)"); + } + catch { /* ignore scroll errors */ } + } + + private async Task ReloadSelectedTask() + { + if (selectedTask == null) return; + try + { + selectedTask = await KanbanService.GetTaskByIdAsync(selectedTask.Id); + StateHasChanged(); + } + catch { /* ignore reload errors */ } + } + + // Todos + private async Task ToggleTodoItem(Guid todoId) + { + try + { + await KanbanService.ToggleTodoAsync(todoId); + await ReloadSelectedTask(); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + private async Task AddTodo() + { + if (string.IsNullOrWhiteSpace(newTodoText) || selectedTask == null) return; + + try + { + await KanbanService.AddTodoAsync(selectedTask.Id, newTodoText.Trim()); + newTodoText = ""; + await ReloadSelectedTask(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + private void StartEditTodo(KanbanTaskTodo todo) + { + editingTodoId = todo.Id; + editingTodoText = todo.Text; + } + + private async Task SaveTodoEdit() + { + if (editingTodoId == null || string.IsNullOrWhiteSpace(editingTodoText)) + { + editingTodoId = null; + return; + } + + try + { + var todo = selectedTask?.Todos?.FirstOrDefault(t => t.Id == editingTodoId); + if (todo != null) + { + todo.Text = editingTodoText.Trim(); + await KanbanService.UpdateTodoAsync(todo); + await ReloadSelectedTask(); + } + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + finally + { + editingTodoId = null; + editingTodoText = ""; + } + } + + private async Task HandleTodoKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") await SaveTodoEdit(); + else if (e.Key == "Escape") { editingTodoId = null; editingTodoText = ""; } + } + + private async Task HandleNewTodoKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTodoText)) + { + await AddTodo(); + } + } + + private async Task DeleteTodo(Guid todoId) + { + try + { + await KanbanService.DeleteTodoAsync(todoId); + await ReloadSelectedTask(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + // Chat + private async Task SendChatMessage() + { + if (string.IsNullOrWhiteSpace(newChatMessage) || selectedTask == null) return; + + try + { + await KanbanService.AddCommentAsync(selectedTask.Id, newChatMessage.Trim(), UserSession.UserId); + newChatMessage = ""; + await ReloadSelectedTask(); + await ScrollChatToBottom(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + // Notes - Simple text field + private async Task SaveTaskNotes() + { + if (selectedTask == null) return; + + try + { + selectedTask.NotesText = taskNotesText; + await KanbanService.UpdateTaskAsync(selectedTask, UserSession.UserId); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Speichern der Notizen: {ex.Message}"; + } + } + + // Tire Size Note - inline editable + private async Task SaveTaskTireSizeNote() + { + if (selectedTask == null) return; + + try + { + selectedTask.TireSizeNote = string.IsNullOrWhiteSpace(taskTireSizeNote) ? null : taskTireSizeNote.Trim(); + await KanbanService.UpdateTaskAsync(selectedTask, UserSession.UserId); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Speichern der Reifengröße: {ex.Message}"; + } + } + + // Attachments + private async Task HandleFileUpload(InputFileChangeEventArgs e) + { + if (selectedTask == null) return; + + isUploading = true; + StateHasChanged(); + + try + { + foreach (var file in e.GetMultipleFiles(10)) + { + if (file.Size > 10 * 1024 * 1024) + { + errorMessage = $"Datei '{file.Name}' ist zu groß (max. 10 MB)."; + continue; + } + + using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + await KanbanService.AddAttachmentAsync(selectedTask.Id, file.Name, file.ContentType, ms.ToArray(), UserSession.UserId); + } + + await ReloadSelectedTask(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Hochladen: {ex.Message}"; + } + finally + { + isUploading = false; + StateHasChanged(); + } + } + + private async Task DeleteAttachment(Guid attachmentId) + { + try + { + await KanbanService.DeleteAttachmentAsync(attachmentId); + await ReloadSelectedTask(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + // Archive + private async Task ArchiveTask() + { + if (selectedTask == null) return; + + try + { + await KanbanService.ArchiveTaskAsync(selectedTask.Id); + CloseTaskDetailModal(); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + // Unarchive (restore) + private async Task UnarchiveTask() + { + if (selectedTask == null) return; + + try + { + await KanbanService.UnarchiveTaskAsync(selectedTask.Id); + CloseTaskDetailModal(); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + // Delete archived task permanently + private async Task DeleteArchivedTask() + { + if (selectedTask == null || !selectedTask.IsArchived) return; + + try + { + await KanbanService.DeleteTaskAsync(selectedTask.Id); + CloseTaskDetailModal(); + await RefreshData(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + private void CancelDeleteTask() + { + confirmDeleteTask = false; + } + + #endregion + + #region Board Search & Filter + + private void ToggleLabelFilter() + { + showLabelFilter = !showLabelFilter; + showCustomerFilter = false; + filterLabelSearch = ""; + filterCustomerSearch = ""; + } + + private void ToggleCustomerFilter() + { + showCustomerFilter = !showCustomerFilter; + showLabelFilter = false; + filterCustomerSearch = ""; + filterLabelSearch = ""; + } + + private void SelectLabelFilter(Guid? labelId) + { + filterLabelId = labelId; + showLabelFilter = false; + filterLabelSearch = ""; + } + + private void SelectCustomerFilter(Guid? customerId) + { + filterCustomerId = customerId; + showCustomerFilter = false; + filterCustomerSearch = ""; + } + + private void ClearAllFilters() + { + boardSearchText = ""; + filterLabelId = null; + filterCustomerId = null; + showLabelFilter = false; + showCustomerFilter = false; + filterLabelSearch = ""; + filterCustomerSearch = ""; + } + + private IEnumerable GetFilteredLabels() + { + if (string.IsNullOrWhiteSpace(filterLabelSearch)) + return labels; + + var search = filterLabelSearch.Trim(); + return labels.Where(l => l.Name.Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + private IEnumerable GetFilteredFilterCustomers() + { + var sorted = customers.OrderBy(c => c.LastName).ThenBy(c => c.FirstName).AsEnumerable(); + + if (string.IsNullOrWhiteSpace(filterCustomerSearch)) + return sorted; + + var search = filterCustomerSearch.Trim(); + return sorted.Where(c => + (c.FullName?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) || + (c.CustomerNumber?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) || + (c.Company?.Contains(search, StringComparison.OrdinalIgnoreCase) == true)); + } + + #endregion + + #region Templates + + private void ShowTemplateModal() + { + showTemplateModal = true; + showTemplateForm = false; + editingTemplate = null; + } + + private void CloseTemplateModal() + { + showTemplateModal = false; + showTemplateForm = false; + editingTemplate = null; + editingTemplateTodos.Clear(); + selectedTemplateLabelIds.Clear(); + newTemplateTodoText = ""; + } + + private void ShowCreateTemplateForm() + { + editingTemplate = new KanbanTaskTemplate + { + Id = Guid.Empty, + DefaultPriority = KanbanTaskPriority.Medium + }; + editingTemplateTodos = new List(); + selectedTemplateLabelIds = new List(); + newTemplateTodoText = ""; + showTemplateForm = true; + } + + private void EditTemplate(KanbanTaskTemplate template) + { + editingTemplate = new KanbanTaskTemplate + { + Id = template.Id, + Name = template.Name, + Description = template.Description, + TitlePattern = template.TitlePattern, + DefaultDescription = template.DefaultDescription, + DefaultColumnId = template.DefaultColumnId, + DefaultPriority = template.DefaultPriority, + DefaultEstimatedMinutes = template.DefaultEstimatedMinutes + }; + editingTemplateTodos = template.Todos? + .OrderBy(t => t.SortOrder) + .Select(t => new KanbanTaskTemplateTodo { Id = t.Id, TemplateId = t.TemplateId, Text = t.Text, SortOrder = t.SortOrder }) + .ToList() ?? new List(); + selectedTemplateLabelIds = template.TemplateLabels? + .Select(tl => tl.LabelId) + .ToList() ?? new List(); + newTemplateTodoText = ""; + showTemplateForm = true; + } + + private void CancelTemplateForm() + { + showTemplateForm = false; + editingTemplate = null; + editingTemplateTodos.Clear(); + selectedTemplateLabelIds.Clear(); + newTemplateTodoText = ""; + } + + private async Task SaveTemplate() + { + if (editingTemplate == null || string.IsNullOrWhiteSpace(editingTemplate.Name)) return; + + try + { + if (editingTemplate.Id == Guid.Empty) + { + var created = await KanbanService.CreateTemplateAsync(editingTemplate); + if (editingTemplateTodos.Count > 0) + { + await KanbanService.SyncTemplateTodosAsync(created.Id, editingTemplateTodos); + } + await KanbanService.SetTemplateLabelsAsync(created.Id, selectedTemplateLabelIds); + } + else + { + await KanbanService.UpdateTemplateAsync(editingTemplate); + await KanbanService.SyncTemplateTodosAsync(editingTemplate.Id, editingTemplateTodos); + await KanbanService.SetTemplateLabelsAsync(editingTemplate.Id, selectedTemplateLabelIds); + } + + templates = await KanbanService.GetTemplatesAsync(); + CancelTemplateForm(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + private async Task DeleteTemplate(Guid templateId) + { + try + { + await KanbanService.DeleteTemplateAsync(templateId); + templates = await KanbanService.GetTemplatesAsync(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + private void OpenCreateFromTemplateModal(KanbanTaskTemplate template) + { + CloseTemplateModal(); + + var defaultColumnId = template.DefaultColumnId + ?? columns.FirstOrDefault(c => c.IsDefault)?.Id + ?? columns.FirstOrDefault()?.Id + ?? Guid.Empty; + + editingTask = new KanbanTask + { + Title = template.TitlePattern ?? string.Empty, + Description = template.DefaultDescription, + ColumnId = defaultColumnId, + Priority = template.DefaultPriority, + EstimatedMinutes = template.DefaultEstimatedMinutes + }; + + isEditingExistingTask = false; + selectedLabelIds = template.TemplateLabels?.Select(tl => tl.LabelId).ToList() ?? new(); + selectedAssigneeIds.Clear(); + selectedCustomerIds.Clear(); + selectedVehicleIds.Clear(); + selectedTireStackIds.Clear(); + availableTireStacks.Clear(); + + activeTemplate = template; + templateTitlePattern = template.TitlePattern; + templateDescriptionPattern = template.DefaultDescription; + pendingTemplateTodos = template.Todos? + .OrderBy(t => t.SortOrder) + .Select(t => t.Text) + .ToList() ?? new(); + + showTaskModal = true; + } + + private static string ResolveClientPlaceholders(string? text, Customer customer, Vehicle? selectedVehicle = null) + { + if (string.IsNullOrEmpty(text)) return text ?? string.Empty; + + var vehicle = selectedVehicle ?? customer.Vehicles? + .Where(v => v.IsActive) + .OrderByDescending(v => v.IsPrimary) + .ThenBy(v => v.CreatedAt) + .FirstOrDefault(); + + var result = text; + result = result.Replace("{Kunde}", customer.FullName); + result = result.Replace("{Vorname}", customer.FirstName ?? ""); + result = result.Replace("{Nachname}", customer.LastName); + result = result.Replace("{Firma}", customer.Company ?? ""); + result = result.Replace("{Kundennummer}", customer.CustomerNumber); + result = result.Replace("{Telefon}", customer.Phone ?? ""); + result = result.Replace("{Email}", customer.Email ?? ""); + result = result.Replace("{Fahrzeug}", vehicle?.DisplayName ?? ""); + result = result.Replace("{Reifengröße}", vehicle?.TireSize ?? ""); + result = result.Replace("{Marke}", vehicle?.Make ?? ""); + result = result.Replace("{Modell}", vehicle?.Model ?? ""); + result = result.Replace("{Datum}", DateTime.Now.ToString("dd.MM.yyyy")); + result = result.Replace("{Jahr}", DateTime.Now.Year.ToString()); + + return result; + } + + private void AddTemplateTodoToForm() + { + if (string.IsNullOrWhiteSpace(newTemplateTodoText)) return; + editingTemplateTodos.Add(new KanbanTaskTemplateTodo + { + Id = Guid.NewGuid(), + Text = newTemplateTodoText.Trim(), + SortOrder = editingTemplateTodos.Count + }); + newTemplateTodoText = ""; + } + + private void RemoveTemplateTodoFromForm(KanbanTaskTemplateTodo todo) + { + editingTemplateTodos.Remove(todo); + } + + private void MoveTemplateTodoUp(int index) + { + if (index <= 0) return; + (editingTemplateTodos[index], editingTemplateTodos[index - 1]) = (editingTemplateTodos[index - 1], editingTemplateTodos[index]); + } + + private void MoveTemplateTodoDown(int index) + { + if (index >= editingTemplateTodos.Count - 1) return; + (editingTemplateTodos[index], editingTemplateTodos[index + 1]) = (editingTemplateTodos[index + 1], editingTemplateTodos[index]); + } + + private void HandleTemplateTodoKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + AddTemplateTodoToForm(); + } + } + + private void ToggleTemplateLabel(Guid labelId, bool selected) + { + if (selected && !selectedTemplateLabelIds.Contains(labelId)) + selectedTemplateLabelIds.Add(labelId); + else if (!selected) + selectedTemplateLabelIds.Remove(labelId); + } + + #endregion + + #region Labels + + private void ShowLabelsModal() + { + showLabelsModal = true; + newLabelName = ""; + newLabelColor = "#3b82f6"; + } + + private void CloseLabelsModal() + { + showLabelsModal = false; + newLabelName = ""; + newLabelColor = "#3b82f6"; + } + + private async Task AddLabel() + { + if (string.IsNullOrWhiteSpace(newLabelName)) return; + + try + { + await KanbanService.CreateLabelAsync(new KanbanLabel + { + Name = newLabelName.Trim(), + Color = newLabelColor + }); + + newLabelName = ""; + newLabelColor = "#3b82f6"; + labels = await KanbanService.GetLabelsAsync(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + private async Task DeleteLabel(Guid labelId) + { + try + { + await KanbanService.DeleteLabelAsync(labelId); + labels = await KanbanService.GetLabelsAsync(); + } + catch (Exception ex) + { + errorMessage = $"Fehler: {ex.Message}"; + } + } + + #endregion + + #region Helpers + + private string GetSeasonIcon(TireSeason season) => season switch + { + TireSeason.Sommer => "☀ï¸�", + TireSeason.Winter => "â�„ï¸�", + TireSeason.Allrad => "🔄", + _ => "🔄" + }; + + private string GetPriorityIcon(KanbanTaskPriority priority) => priority switch + { + KanbanTaskPriority.Low => "⬇ï¸�", + KanbanTaskPriority.Medium => "âž¡ï¸�", + KanbanTaskPriority.High => "⬆ï¸�", + KanbanTaskPriority.Urgent => "🔥", + _ => "âž¡ï¸�" + }; + + private string GetPriorityText(KanbanTaskPriority priority) => priority switch + { + KanbanTaskPriority.Low => "Niedrig", + KanbanTaskPriority.Medium => "Mittel", + KanbanTaskPriority.High => "Hoch", + KanbanTaskPriority.Urgent => "Dringend", + _ => "Mittel" + }; + + private char GetUserInitial(AppUser? user) + { + if (user == null) return '?'; + var name = user.DisplayName ?? user.Username ?? "?"; + return name.Length > 0 ? char.ToUpper(name[0]) : '?'; + } + + #endregion +} diff --git a/Lagerbestand/Components/Pages/Dashboard.razor.css b/Lagerbestand/Components/Pages/Dashboard.razor.css new file mode 100644 index 0000000..3bd6277 --- /dev/null +++ b/Lagerbestand/Components/Pages/Dashboard.razor.css @@ -0,0 +1,1884 @@ +/* Dashboard Page Styles - Kanban Board (Dark Theme) */ +.dashboard-page { + padding: var(--space-page-x, 1.5rem); + max-width: 100%; + min-height: 100vh; + background: transparent; +} + +/* Header */ +.page-header { + margin-bottom: var(--space-6, 1.5rem); +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.header-content h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary, #ffffff); + font-family: var(--font-family-heading, inherit); +} + +.header-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Alert (page-specific override — base .alert and .alert-danger inherited from app.css section 7) */ +.alert { + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.alert-close { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.alert-close:hover { + opacity: 1; +} + +/* Statistics Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: var(--space-6, 1.5rem); +} + +.stat-card { + background: var(--surface-primary, rgba(28, 28, 30, 0.95)); + border-radius: var(--radius-lg, 0.75rem); + padding: 1rem 1.25rem; + box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.4)); + display: flex; + flex-direction: column; + gap: 0.25rem; + border-left: 4px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-left-width: 4px; +} + +.stat-card.warning { + border-left-color: var(--color-warning, #fbbf24); +} + +.stat-card.danger { + border-left-color: var(--color-error, #ef4444); +} + +.stat-card.success { + border-left-color: var(--color-success, #22c55e); +} + +.stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary, #ffffff); + font-family: var(--font-family-heading, inherit); +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +/* Board Toolbar - Search & Filters */ +.board-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.search-box { + position: relative; + flex: 1; + min-width: 200px; + max-width: 400px; +} + +.search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.875rem; + pointer-events: none; + opacity: 0.5; +} + +.search-input { + width: 100%; + padding: 0.5rem 2rem 0.5rem 2.25rem; + border: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 0.375rem); + font-size: 0.875rem; + background: rgba(0, 0, 0, 0.3); + color: var(--text-primary, #ffffff); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-input::placeholder { + color: var(--text-placeholder, rgba(255, 255, 255, 0.3)); +} + +.search-input:focus { + outline: none; + border-color: var(--color-primary, #dc2626); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); +} + +.search-clear { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary, rgba(255, 255, 255, 0.5)); + font-size: 1.1rem; + cursor: pointer; + line-height: 1; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; +} + +.search-clear:hover { + color: var(--text-primary, #ffffff); + background: rgba(255, 255, 255, 0.1); +} + +.filter-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.filter-dropdown-wrapper { + position: relative; +} + +.filter-btn.active { + border-color: var(--color-primary, #dc2626); + background: rgba(220, 38, 38, 0.15); + color: var(--text-primary, #ffffff); +} + +.filter-badge { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-left: 0.25rem; +} + +.filter-active-text { + font-size: 0.75rem; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 200px; + max-height: 300px; + overflow-y: auto; + background: var(--surface-primary, rgba(28, 28, 30, 0.98)); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 0.5rem); + box-shadow: var(--shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.5)); + z-index: 100; +} + +.filter-dropdown-customers { + min-width: 240px; +} + +.filter-search-box { + position: sticky; + top: 0; + padding: 0.5rem; + background: var(--surface-primary, rgba(28, 28, 30, 0.98)); + border-bottom: 0.5px solid rgba(255, 255, 255, 0.1); + z-index: 1; +} + +.filter-search-input { + width: 100%; + padding: 0.4rem 0.6rem; + background: rgba(255, 255, 255, 0.06); + border: 0.5px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-sm, 0.25rem); + color: var(--text-primary, #ffffff); + font-family: inherit; + font-size: 0.8rem; + outline: none; + transition: border-color 0.15s; +} + +.filter-search-input::placeholder { + color: var(--text-tertiary, rgba(255, 255, 255, 0.4)); +} + +.filter-search-input:focus { + border-color: var(--color-primary, #dc2626); + background: rgba(255, 255, 255, 0.08); +} + +.filter-dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: none; + border: none; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.05); + color: var(--text-primary, #ffffff); + font-family: inherit; + font-size: 0.85rem; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.filter-dropdown-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.filter-dropdown-item:last-child { + border-bottom: none; +} + +.filter-dropdown-item.selected { + background: rgba(220, 38, 38, 0.15); + color: var(--color-primary-light, #ef4444); + font-weight: 500; +} + +.filter-label-dot { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.filter-customer-number { + margin-left: auto; + font-size: 0.7rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); + font-family: var(--font-family-heading), monospace; +} + +.filter-clear-btn { + color: var(--color-error-light, #f87171) !important; + border-color: rgba(239, 68, 68, 0.3) !important; +} + +.filter-clear-btn:hover { + background: rgba(239, 68, 68, 0.15) !important; +} + +@media (max-width: 640px) { + .board-toolbar { + flex-direction: column; + align-items: stretch; + } + + .search-box { + max-width: none; + } + + .filter-actions { + justify-content: flex-start; + } +} + +/* Kanban Board */ +.kanban-board { + display: flex; + gap: 1rem; + overflow-x: auto; + padding-bottom: 1rem; + min-height: calc(100vh - 320px); + scroll-behavior: smooth; +} + +.kanban-column { + flex: 1 1 0; + min-width: 250px; + max-width: none; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: var(--radius-lg, 0.75rem); + display: flex; + flex-direction: column; + max-height: calc(100vh - 300px); + transition: background-color 0.2s, box-shadow 0.2s; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +/* On smaller screens, use fixed width columns */ +@media (max-width: 1200px) { + .kanban-column { + flex: 0 0 280px; + } +} + +.kanban-column.drag-over { + background: rgba(220, 38, 38, 0.15); + box-shadow: 0 0 0 2px var(--color-primary, #dc2626); +} + +.column-header { + padding: 1rem; + border-top: 4px solid var(--color-primary, #dc2626); + border-radius: var(--radius-lg, 0.75rem) var(--radius-lg, 0.75rem) 0 0; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--surface-tertiary, rgba(58, 58, 60, 0.8)); + flex-shrink: 0; +} + +.column-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.column-title h2 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #ffffff); +} + +.column-icon { + font-size: 1.1rem; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + line-height: 1; +} + +.task-count { + background: rgba(255, 255, 255, 0.15); + padding: 0.125rem 0.5rem; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.column-add-btn { + width: 28px; + height: 28px; + border: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 0.375rem; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + transition: all 0.2s; +} + +.column-add-btn:hover { + background: var(--color-primary, #dc2626); + color: var(--text-primary, #ffffff); +} + +.column-tasks { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 100px; +} + +/* Task Card */ +.task-card { + background: var(--surface-primary, rgba(28, 28, 30, 0.95)); + border-radius: var(--radius-md, 0.5rem); + padding: 0.75rem; + box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.3)); + cursor: pointer; + transition: all 0.2s; + border-left: 3px solid transparent; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-left-width: 3px; + user-select: none; +} + +.task-card:hover { + box-shadow: var(--shadow-glow, 0 0 20px rgba(220, 38, 38, 0.4)); + border-color: var(--color-primary, #dc2626); + transform: translateY(-1px); +} + +.task-card.dragging { + opacity: 0.5; + transform: rotate(2deg); +} + +.task-card.overdue { + border-left-color: var(--color-error, #ef4444); + background: rgba(239, 68, 68, 0.1); +} + +.task-card.due-today { + border-left-color: var(--color-warning, #fbbf24); + background: rgba(251, 191, 36, 0.1); +} + +.task-card.priority-urgent { + border-left-color: var(--color-error, #ef4444); +} + +.task-card.priority-high { + border-left-color: var(--color-warning, #fbbf24); +} + +.task-card.priority-medium { + border-left-color: var(--color-info, #60a5fa); +} + +.task-card.priority-low { + border-left-color: var(--color-success, #22c55e); +} + +.task-card.archived { + opacity: 0.55; + border-left-color: var(--text-tertiary, rgba(255, 255, 255, 0.3)); + background: rgba(255, 255, 255, 0.03); + border-style: dashed; +} + +.task-card.archived:hover { + opacity: 0.85; + border-color: var(--text-secondary, rgba(255, 255, 255, 0.5)); + box-shadow: none; +} + +.archived-badge { + margin-right: 0.25rem; + font-size: 0.8em; +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.task-title { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary, #ffffff); + word-break: break-word; + flex: 1; +} + +.task-dropdown { + flex-shrink: 0; +} + +.column-select { + font-size: 0.7rem; + padding: 0.125rem 0.25rem; + border: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: 0.25rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + cursor: pointer; + max-width: 80px; +} + +.column-select option { + background: #1c1c1e; + color: var(--text-primary, #ffffff); +} + +.task-labels { + display: flex; + gap: 0.25rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.label-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.label-more { + font-size: 0.7rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.task-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.task-meta-icons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.meta-icon { + font-size: 0.7rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + display: flex; + align-items: center; + gap: 0.125rem; + white-space: nowrap; +} + +.meta-icon.due-date.overdue { + color: var(--color-error-light, #f87171); + font-weight: 600; +} + +.meta-icon.due-date.due-today { + color: var(--color-warning-light, #fde047); + font-weight: 600; +} + +.task-assignees { + display: flex; + margin-left: auto; +} + +.assignee-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-primary, #dc2626); + color: var(--text-primary, #ffffff); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + margin-left: -6px; + border: 2px solid var(--surface-primary, rgba(28, 28, 30, 0.95)); +} + +.assignee-avatar:first-child { + margin-left: 0; +} + +.assignee-more { + font-size: 0.7rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + margin-left: 0.25rem; + align-self: center; +} + +/* Modal Styles */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: var(--z-modal-backdrop, 900); + backdrop-filter: blur(4px); +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--surface-primary, rgba(28, 28, 30, 0.98)); + border-radius: var(--radius-lg, 0.75rem); + box-shadow: var(--shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.5)); + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + width: 90%; + max-width: 500px; + z-index: var(--z-modal, 1000); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.modal.large { + max-width: 1000px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + gap: 1rem; + flex-shrink: 0; +} + +.modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #ffffff); +} + +.modal-header-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + line-height: 1; + padding: 0.25rem; + border-radius: 0.25rem; +} + +.modal-close:hover { + color: var(--text-primary, #ffffff); + background: rgba(255, 255, 255, 0.1); +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + flex-shrink: 0; +} + +/* Form Styles */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + margin-bottom: 0.375rem; +} + +.form-control { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 0.375rem); + font-size: 0.9rem; + background: rgba(0, 0, 0, 0.3); + color: var(--text-primary, #ffffff); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-control::placeholder { + color: var(--text-placeholder, rgba(255, 255, 255, 0.3)); +} + +.form-control:focus { + outline: none; + border-color: var(--color-primary, #dc2626); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); +} + +.form-control option { + background: #1c1c1e; + color: var(--text-primary, #ffffff); +} + +.form-control-color { + width: 50px; + height: 38px; + padding: 0.25rem; + border: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 0.375rem); + cursor: pointer; + background: rgba(0, 0, 0, 0.3); +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 480px) { + .form-row { + grid-template-columns: 1fr; + } +} + +/* Label Selector */ +.label-selector { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.label-checkbox { + display: flex; + align-items: center; + cursor: pointer; +} + +.label-checkbox input { + display: none; +} + +.label-checkbox .label-tag { + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + background: var(--label-color, #6b7280); + color: white; + opacity: 0.5; + transition: opacity 0.2s; +} + +.label-checkbox input:checked + .label-tag { + opacity: 1; +} + +/* Assignee Selector */ +.assignee-selector { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.assignee-checkbox { + display: flex; + align-items: center; + gap: 0.375rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: 0.375rem; + font-size: 0.875rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + transition: all 0.2s; +} + +.assignee-checkbox:has(input:checked) { + border-color: var(--color-primary, #dc2626); + background: rgba(220, 38, 38, 0.15); + color: var(--text-primary, #ffffff); +} + +/* Customer Selection */ +.selected-customers { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.customer-tag { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: 0.375rem; + font-size: 0.875rem; + color: var(--text-primary, #ffffff); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.customer-tag .remove-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + line-height: 1; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + padding: 0; +} + +.customer-tag .remove-btn:hover { + color: var(--color-error, #ef4444); +} + +/* Vehicle selector per customer */ +.customer-tag-row { + display: flex; + flex-direction: column; + gap: 0.375rem; + width: 100%; +} + +.customer-vehicle-badge { + font-size: 0.75rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); + font-weight: 400; +} + +.vehicle-select { + min-height: 36px !important; + padding: 0.25rem 0.5rem !important; + font-size: 0.8125rem !important; + background-color: rgba(0, 0, 0, 0.25) !important; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)) !important; + border-radius: 0.375rem !important; + color: var(--text-primary, #fff) !important; +} + +.vehicle-select:focus { + border-color: var(--border-focus, rgba(220, 38, 38, 0.5)) !important; + box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.15) !important; +} + +/* Tire Stack Selector */ +.tirestack-selector { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 150px; + overflow-y: auto; +} + +.tirestack-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.5rem; + border: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: 0.375rem; + transition: all 0.2s; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.tirestack-checkbox:has(input:checked) { + border-color: var(--color-primary, #dc2626); + background: rgba(220, 38, 38, 0.15); + color: var(--text-primary, #ffffff); +} + +.tirestack-info { + font-size: 0.875rem; +} + +.tirestack-info small { + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); +} + +/* Task Detail Modal */ +.task-detail-modal .modal-body { + padding: 0; +} + +.task-detail-header-content { + flex: 1; + min-width: 0; +} + +.task-detail-header-content h2 { + word-break: break-word; + color: var(--text-primary, #ffffff); +} + +.task-meta { + display: flex; + gap: 0.75rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.priority-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +.priority-badge.priority-low { + background: rgba(34, 197, 94, 0.2); + color: var(--color-success-light, #86efac); +} + +.priority-badge.priority-medium { + background: rgba(96, 165, 250, 0.2); + color: var(--color-info-light, #93c5fd); +} + +.priority-badge.priority-high { + background: rgba(251, 191, 36, 0.2); + color: var(--color-warning-light, #fde047); +} + +.priority-badge.priority-urgent { + background: rgba(239, 68, 68, 0.2); + color: var(--color-error-light, #f87171); +} + +.priority-badge.archived-indicator { + background: rgba(156, 163, 175, 0.2); + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + border: 1px dashed rgba(156, 163, 175, 0.4); +} + +.due-date { + font-size: 0.875rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.due-date.overdue { + color: var(--color-error-light, #f87171); + font-weight: 600; +} + +.due-date.due-today { + color: var(--color-warning-light, #fde047); + font-weight: 600; +} + +.task-detail-body { + display: grid; + grid-template-columns: 1fr 300px; + min-height: 400px; +} + +@media (max-width: 768px) { + .task-detail-body { + grid-template-columns: 1fr; + } + + .task-detail-sidebar { + border-left: none; + border-top: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + max-height: 300px; + } +} + +.task-detail-main { + padding: 1.5rem; + overflow-y: auto; + max-height: 60vh; +} + +.task-detail-sidebar { + border-left: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + display: flex; + flex-direction: column; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); +} + +.task-detail-sidebar h3 { + margin: 0; + padding: 1rem; + font-size: 0.95rem; + border-bottom: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + flex-shrink: 0; + color: var(--text-primary, #ffffff); +} + +/* Detail Sections */ +.detail-section { + margin-bottom: 1.5rem; +} + +.detail-section h3 { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + margin: 0 0 0.75rem 0; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.description-content { + color: var(--text-primary, #ffffff); + line-height: 1.6; + white-space: pre-wrap; +} + +.tire-size-badge { + display: inline-block; + padding: 0.35rem 0.75rem; + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 6px; + color: #93c5fd; + font-weight: 500; + font-size: 0.9rem; + letter-spacing: 0.02em; +} + +.tire-size-input { + max-width: 300px; + font-size: 0.9rem; + letter-spacing: 0.02em; +} + +.text-muted { + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); + font-style: italic; +} + +/* Labels List */ +.label-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.label-tag { + padding: 0.25rem 0.625rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + color: white; +} + +/* Assignee List */ +.assignee-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.assignee-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: 0.375rem; + font-size: 0.875rem; + color: var(--text-primary, #ffffff); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.assignee-badge .assignee-avatar { + width: 20px; + height: 20px; + font-size: 0.6rem; + margin: 0; + border: none; +} + +/* Customer/TireStack Lists */ +.customer-list, +.tirestack-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.customer-card, +.tirestack-card { + padding: 0.75rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: var(--radius-md, 0.5rem); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.customer-name { + font-weight: 500; + color: var(--text-primary, #ffffff); +} + +.customer-vehicles { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.375rem; +} + +.vehicle-badge { + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.tirestack-card { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.season-icon { + font-size: 1.25rem; + flex-shrink: 0; +} + +.tirestack-card .tirestack-info { + display: flex; + flex-direction: column; +} + +.tirestack-card .vehicle { + font-weight: 500; + color: var(--text-primary, #ffffff); +} + +.tirestack-card .details { + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +/* Todo List */ +.todo-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.todo-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: 0.375rem; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.todo-item.completed .todo-text { + text-decoration: line-through; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); +} + +.todo-item input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; + accent-color: var(--color-primary, #dc2626); +} + +.todo-text { + flex: 1; + font-size: 0.9rem; + word-break: break-word; + color: var(--text-primary, #ffffff); +} + +.todo-edit-input { + flex: 1; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-primary, #dc2626); + border-radius: 0.25rem; + font-size: 0.9rem; + background: rgba(0, 0, 0, 0.3); + color: var(--text-primary, #ffffff); +} + +.todo-delete { + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); + font-size: 1.1rem; + line-height: 1; + opacity: 0; + transition: all 0.2s; + flex-shrink: 0; +} + +.todo-item:hover .todo-delete { + opacity: 1; +} + +.todo-delete:hover { + color: var(--color-error, #ef4444); +} + +.todo-add { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.todo-add .form-control { + flex: 1; +} + +/* Attachments */ +.attachment-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.attachment-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: 0.375rem; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.attachment-icon { + font-size: 1.25rem; + flex-shrink: 0; +} + +.attachment-info { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.attachment-name { + font-size: 0.9rem; + color: var(--color-primary-light, #ef4444); + text-decoration: none; + word-break: break-word; +} + +.attachment-size { + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.attachment-delete { + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); + font-size: 1.1rem; + transition: color 0.2s; + flex-shrink: 0; +} + +.attachment-delete:hover { + color: var(--color-error, #ef4444); +} + +.attachment-upload { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.attachment-upload ::deep input[type="file"] { + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.upload-hint { + font-size: 0.75rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); +} + +.upload-progress { + font-size: 0.875rem; + color: var(--color-primary-light, #ef4444); + padding: 0.5rem; + background: rgba(220, 38, 38, 0.1); + border-radius: 0.25rem; + margin-top: 0.5rem; +} + +/* Notes - Simple textarea */ +.notes-textarea { + min-height: 8rem; + resize: vertical; + font-family: inherit; + line-height: 1.6; +} + +/* Chat/Activity Sidebar */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 200px; +} + +.chat-message { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.chat-message.activity { + opacity: 0.7; +} + +.activity-content { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); + flex-wrap: wrap; +} + +.activity-icon { + font-size: 0.9rem; + flex-shrink: 0; +} + +.activity-text { + flex: 1; + min-width: 0; +} + +.activity-time { + font-size: 0.7rem; + flex-shrink: 0; +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.message-author { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary, #ffffff); +} + +.message-time { + font-size: 0.7rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); +} + +.message-content { + font-size: 0.875rem; + color: var(--text-primary, #ffffff); + background: var(--surface-tertiary, rgba(58, 58, 60, 0.8)); + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md, 0.5rem); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); + word-break: break-word; +} + +.chat-input { + padding: 0.75rem; + border-top: 1px solid var(--border-default, rgba(255, 255, 255, 0.1)); + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-shrink: 0; +} + +.chat-input textarea { + resize: none; +} + +/* Task Detail Modal Footer */ +.task-detail-modal .modal-footer { + justify-content: space-between; +} + +.footer-left { + display: flex; + align-items: center; +} + +.task-info { + font-size: 0.75rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.5)); +} + +.footer-right { + display: flex; + gap: 0.5rem; +} + +/* Templates Modal */ +.template-modal { + max-width: 650px; +} + +.template-actions { + margin-bottom: 1rem; +} + +.template-form { + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + padding: 1rem; + border-radius: var(--radius-md, 0.5rem); + margin-bottom: 1rem; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.template-form h3 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--text-primary, #ffffff); +} + +/* Placeholder Legend */ +.placeholder-legend { + margin-bottom: 1rem; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 0.5rem); + background: var(--surface-tertiary, rgba(58, 58, 60, 0.6)); + overflow: hidden; +} + +.placeholder-legend summary { + padding: 0.625rem 0.75rem; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-primary, #ffffff); + user-select: none; +} + +.placeholder-legend summary:hover { + background: rgba(255, 255, 255, 0.03); +} + +.placeholder-legend .legend-hint { + font-weight: 400; + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.5)); +} + +.placeholder-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; + padding: 0.5rem 0.75rem 0.75rem; +} + +.placeholder-group h5 { + margin: 0 0 0.375rem 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.placeholder-item { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); + padding: 0.125rem 0; +} + +.placeholder-item code { + background: rgba(59, 130, 246, 0.15); + color: var(--color-primary, #3b82f6); + padding: 0.0625rem 0.3125rem; + border-radius: 0.1875rem; + font-size: 0.6875rem; + font-family: 'SF Mono', 'Menlo', monospace; + white-space: nowrap; +} + +/* Template Todos */ +.template-todos { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.template-todo-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.template-todo-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + background: var(--surface-tertiary, rgba(58, 58, 60, 0.6)); + border-radius: var(--radius-sm, 0.25rem); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.08)); +} + +.template-todo-item .todo-reorder { + display: flex; + flex-direction: column; + gap: 0; +} + +.template-todo-item .todo-text { + flex: 1; + font-size: 0.8125rem; + color: var(--text-primary, #ffffff); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-icon { + background: none; + border: none; + color: var(--text-secondary, rgba(255, 255, 255, 0.5)); + cursor: pointer; + padding: 0.0625rem 0.25rem; + font-size: 0.625rem; + line-height: 1; + border-radius: 0.125rem; +} + +.btn-icon:hover:not(:disabled) { + color: var(--text-primary, #ffffff); + background: rgba(255, 255, 255, 0.08); +} + +.btn-icon:disabled { + opacity: 0.3; + cursor: default; +} + +.btn-icon-danger:hover:not(:disabled) { + color: var(--color-error-light, #f87171); + background: rgba(239, 68, 68, 0.15); +} + +.template-todo-add { + display: flex; + gap: 0.375rem; + align-items: center; +} + +.template-todo-add .form-control { + flex: 1; + font-size: 0.8125rem; +} + +/* Template Todos Preview (in task modal) */ +.template-todos-preview { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.625rem 0.75rem; + background: var(--surface-tertiary, rgba(58, 58, 60, 0.6)); + border-radius: var(--radius-sm, 0.25rem); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.08)); + max-height: 180px; + overflow-y: auto; +} + +.template-todo-preview-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + font-size: 0.8125rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.template-todo-preview-item .todo-check { + color: var(--text-tertiary, rgba(255, 255, 255, 0.4)); + font-size: 0.875rem; + flex-shrink: 0; +} + +.template-form-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +.template-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.template-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + padding: 1rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: var(--radius-md, 0.5rem); + gap: 0.75rem; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +.template-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.template-name { + font-weight: 500; + color: var(--text-primary, #ffffff); +} + +.template-description { + font-size: 0.875rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.template-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.meta-item { + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + background: var(--surface-tertiary, rgba(58, 58, 60, 0.8)); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +} + +.template-labels { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.25rem; +} + +.template-item-actions { + display: flex; + gap: 0.375rem; + flex-shrink: 0; + flex-wrap: wrap; +} + +/* Labels Modal */ +.labels-modal { + max-width: 400px; +} + +.label-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.label-form .form-control { + flex: 1; +} + +.label-list-manage { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.label-list-manage .label-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + background: var(--surface-secondary, rgba(44, 44, 46, 0.9)); + border-radius: 0.375rem; + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); +} + +/* Buttons — .btn, .btn-primary, .btn-outline, .btn-danger, .btn-sm inherited from app.css section 5 */ + +.spinner-sm { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +/* Loading & Empty States */ +.loading-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + gap: 1rem; + text-align: center; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-top-color: var(--color-primary, #dc2626); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.empty-icon { + font-size: 3rem; +} + +/* .empty-state h2/p, @keyframes spin, .access-denied — inherited from app.css sections 11, 19 */ + +/* Responsive */ +@media (max-width: 640px) { + .dashboard-page { + padding: 1rem; + } + + .kanban-column { + flex: 0 0 280px; + } + + .modal { + width: 95%; + margin: 0.5rem; + } + + .header-actions { + width: 100%; + justify-content: flex-end; + } +} + +/* Additional scrollbar styling for dark theme */ +.kanban-board::-webkit-scrollbar, +.column-tasks::-webkit-scrollbar, +.modal-body::-webkit-scrollbar, +.task-detail-main::-webkit-scrollbar, +.chat-messages::-webkit-scrollbar, +.note-content::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.kanban-board::-webkit-scrollbar-track, +.column-tasks::-webkit-scrollbar-track, +.modal-body::-webkit-scrollbar-track, +.task-detail-main::-webkit-scrollbar-track, +.chat-messages::-webkit-scrollbar-track, +.note-content::-webkit-scrollbar-track { + background: transparent; +} + +.kanban-board::-webkit-scrollbar-thumb, +.column-tasks::-webkit-scrollbar-thumb, +.modal-body::-webkit-scrollbar-thumb, +.task-detail-main::-webkit-scrollbar-thumb, +.chat-messages::-webkit-scrollbar-thumb, +.note-content::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.kanban-board::-webkit-scrollbar-thumb:hover, +.column-tasks::-webkit-scrollbar-thumb:hover, +.modal-body::-webkit-scrollbar-thumb:hover, +.task-detail-main::-webkit-scrollbar-thumb:hover, +.chat-messages::-webkit-scrollbar-thumb:hover, +.note-content::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* ======================================== + Customer Search Dropdown + ======================================== */ +.customer-search-wrapper { + position: relative; +} + +.customer-search-results { + max-height: 180px; + overflow-y: auto; + margin-top: 4px; + background: var(--surface-secondary, rgba(44, 44, 46, 0.95)); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 0.5rem); +} + +.customer-search-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 0.5rem 0.75rem; + background: none; + border: none; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.05); + color: var(--text-primary, #ffffff); + font-family: inherit; + font-size: 0.875rem; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.customer-search-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.customer-search-item:last-child { + border-bottom: none; +} + +.customer-search-name { + font-weight: 500; +} + +.customer-search-number { + font-size: 0.75rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.5)); + font-family: var(--font-family-heading), monospace; +} + +.customer-search-empty { + padding: 0.75rem; + text-align: center; + color: var(--text-secondary, rgba(255, 255, 255, 0.5)); + font-size: 0.85rem; +} diff --git a/Lagerbestand/Components/Pages/Datenschutz.razor b/Lagerbestand/Components/Pages/Datenschutz.razor new file mode 100644 index 0000000..6f83842 --- /dev/null +++ b/Lagerbestand/Components/Pages/Datenschutz.razor @@ -0,0 +1,156 @@ +@page "/datenschutz" +@layout EmptyLayout + +Datenschutzerklärung | Reifen Späth Kfz-Meisterbetrieb + + + +
+ + +
+
+

Datenschutzerklärung

+

Diese Datenschutzerklärung gilt für die Website www.reifenspaeth.de.

+ +
+

1. Verantwortlicher

+
+ Reifen Späth
+ Inhaber: Daniel Späth
+ Sandizeller Straße 10C
+ 86571 Langenmosen
+ Telefon: +49 (0)155 68569616
+ E-Mail: info@reifenspaeth.de
+ Website: www.reifenspaeth.de +
+
+ +
+

2. Allgemeine Hinweise zur Datenverarbeitung

+

+ Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Personenbezogene Daten werden vertraulich + und entsprechend den gesetzlichen Datenschutzvorschriften (DSGVO, BDSG) sowie dieser Datenschutzerklärung behandelt. +

+
+ +
+

3. Erhebung und Speicherung personenbezogener Daten sowie Art und Zweck von deren Verwendung

+ +

a) Beim Besuch der Website

+

+ Beim Aufrufen unserer Website werden automatisch Informationen (sogenannte Server-Logfiles) + an unseren Server übermittelt und temporär gespeichert. Dazu gehören u. a.: +

+
    +
  • IP-Adresse des anfragenden Rechners
  • +
  • Datum und Uhrzeit des Zugriffs
  • +
  • Name und URL der abgerufenen Datei
  • +
  • Referrer-URL (Website, von der aus der Zugriff erfolgt)
  • +
  • verwendeter Browser und Betriebssystem
  • +
+

+ 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). +

+
+ +
+

4. Cookies und Tracking

+ +

a) Cookies

+

+ 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. +

+ +

b) Tracking – Google Analytics

+

+ Auf dieser Website wird Google Analytics 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. +

+

+ 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: + https://tools.google.com/dlpage/gaoptout. +

+
+ +
+

5. Datenweitergabe

+

+ Eine Übermittlung personenbezogener Daten an Dritte erfolgt nur, wenn Sie eingewilligt haben, + die Verarbeitung zur Vertragserfüllung erforderlich ist oder eine gesetzliche Verpflichtung besteht. +

+
+ +
+

6. Server-Hosting

+

+ 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. +

+
+ +
+

7. Ihre Rechte

+

Sie haben die folgenden Rechte hinsichtlich gespeicherter personenbezogener Daten:

+
    +
  • Auskunft (Art. 15 DSGVO)
  • +
  • Berichtigung (Art. 16 DSGVO)
  • +
  • Löschung (Art. 17 DSGVO)
  • +
  • Einschränkung der Verarbeitung (Art. 18 DSGVO)
  • +
  • Widerspruch (Art. 21 DSGVO)
  • +
  • Datenübertragbarkeit (Art. 20 DSGVO)
  • +
+

Zur Ausübung Ihrer Rechte genügt eine Nachricht an: info@reifenspaeth.de.

+
+ +
+

8. Beschwerderecht bei der Aufsichtsbehörde

+

+ Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. In Bayern z. B.: +

+
+ Bayerische Landesbeauftragte für den Datenschutz (BayLfD)
+ Wagmüllerstraße 18, 80538 München
+ Telefon: +49 89 212672-0
+ Website: https://www.datenschutz-bayern.de +
+
+ +
+

9. Stand und Aktualisierung

+

+ Diese Datenschutzerklärung hat den Stand . + Wir behalten uns vor, die Erklärung bei Bedarf anzupassen. +

+
+
+ + +
+
diff --git a/Lagerbestand/Components/Pages/Datenschutz.razor.css b/Lagerbestand/Components/Pages/Datenschutz.razor.css new file mode 100644 index 0000000..c462d45 --- /dev/null +++ b/Lagerbestand/Components/Pages/Datenschutz.razor.css @@ -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); + } +} diff --git a/Lagerbestand/Components/Pages/Error.razor b/Lagerbestand/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/Lagerbestand/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@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; +} diff --git a/Lagerbestand/Components/Pages/Home.razor b/Lagerbestand/Components/Pages/Home.razor new file mode 100644 index 0000000..3083bc2 --- /dev/null +++ b/Lagerbestand/Components/Pages/Home.razor @@ -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 + +Reifen Späth Langenmosen | Reifenwechsel, Felgen & Reifeneinlagerung + + + +
+ @* Cookie Consent Banner *@ + @if (!cookieConsent) + { + + } + + @* Navigation *@ +
+ +
+ + @* Promo Banner *@ + @if (promoBanner != null && promoBanner.ShouldShow()) + { + + } + +
+ @* Hero Section *@ +
+ +
+

+ REIFEN SPÄTH LANGENMOSEN + REIFENWECHSEL & FELGEN +

+

+ Kfz-Meisterbetrieb
+ Ihr Spezialist für Reifenwechsel, Felgen-Service und Reifeneinlagerung nahe Neuburg an der Donau und Schrobenhausen +

+ +
+ +
+ + @* About Section *@ +
+
+
+

ÜBER UNS

+

+ 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. +

+
+
+
+ +

Meisterqualität

+

Als Kfz-Meisterbetrieb garantieren wir höchste Qualitätsstandards und präzise Arbeit nach neuesten Technologien.

+
+
+ +

Schneller Service

+

Effiziente Terminvergabe und schnelle Ausführung aller Arbeiten - damit Sie schnell wieder sicher unterwegs sind.

+
+
+ +

Faire Preise

+

Transparente Preisgestaltung und wettbewerbsfähige Angebote - Qualität zum fairen Preis.

+
+
+
+
+ + @* Services Section *@ +
+
+
+

UNSERE SERVICES

+

+ Von der Beratung bis zur Montage - wir bieten Ihnen alle Services rund um Ihre Reifen und Felgen aus einer Hand. +

+
+
+
+
+ Professioneller Reifenwechsel bei Reifen Späth +
+

Reifenwechsel

+

Professioneller Reifenwechsel in Langenmosen mit Auswuchten, Druckkontrolle und Sichtprüfung für Sommerreifen und Winterreifen.

+
+
+
+ Felgen-Service - Alufelgen und Stahlfelgen +
+

Felgen-Service

+

Alufelgen, Stahlfelgen und Spezialfelgen - Beratung, Verkauf, Montage und Reparatur durch den Kfz-Meisterbetrieb.

+
+
+
+ Professionelles Reifenlager für sichere Einlagerung +
+

Reifenlager

+

Professionelle Reifeneinlagerung: trocken, sauber und ganzjährig verfügbar für Kunden aus Langenmosen, Neuburg und Schrobenhausen.

+
+
+
+
+ + @* Local SEO Section *@ +
+
+
+

REIFENSERVICE IN IHRER NÄHE

+

+ Reifen Späth betreut Privat- und Gewerbekunden in Langenmosen sowie im Umkreis von Neuburg an der Donau, Schrobenhausen, Ingolstadt und Aichach-Friedberg. +

+
+
+
+

Reifenwechsel vor Ort

+

Schnelle Termine für den saisonalen Wechsel auf Sommerreifen oder Winterreifen inklusive Kontrolle von Profil, Luftdruck und Zustand.

+
+
+

Felgen & Kompletträder

+

Persönliche Beratung zu passenden Alufelgen, Stahlfelgen und Kompletträdern für Ihr Fahrzeug.

+
+
+

Einlagerung mit Service

+

Ihre Räder werden fachgerecht gelagert und sind zur nächsten Saison wieder einsatzbereit.

+
+
+
+
+ + @* FAQ Section *@ +
+
+
+

HÄUFIGE FRAGEN

+
+
+
+

Wann sollte ich die Reifen wechseln?

+

Sommerreifen eignen sich für warme Monate, Winterreifen für niedrige Temperaturen und winterliche Straßen. Vereinbaren Sie rechtzeitig vor der Saison einen Termin.

+
+
+

Bietet Reifen Späth Reifeneinlagerung an?

+

Ja. Ihre Reifen oder Kompletträder können professionell eingelagert werden, damit sie trocken und sauber bis zur nächsten Saison aufbewahrt sind.

+
+
+

Wie erreiche ich den Reifenservice?

+

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.

+
+
+
+
+ + @* CTA Section *@ +
+
+

BEREIT FÜR NEUE REIFEN?

+

+ Vereinbaren Sie jetzt Ihren Termin oder kontaktieren Sie uns für eine persönliche Beratung. + Wir stehen Ihnen gerne zur Verfügung. +

+ +
+
+
+ + @* Footer *@ +
+
+ + +
+
+
+ +@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("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("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/"" + }] + } + }"; + } +} diff --git a/Lagerbestand/Components/Pages/Home.razor.css b/Lagerbestand/Components/Pages/Home.razor.css new file mode 100644 index 0000000..429ac46 --- /dev/null +++ b/Lagerbestand/Components/Pages/Home.razor.css @@ -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
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 */ + } +} diff --git a/Lagerbestand/Components/Pages/Impressum.razor b/Lagerbestand/Components/Pages/Impressum.razor new file mode 100644 index 0000000..80b9629 --- /dev/null +++ b/Lagerbestand/Components/Pages/Impressum.razor @@ -0,0 +1,111 @@ +@page "/impressum" +@layout EmptyLayout + +Impressum | Reifen Späth Kfz-Meisterbetrieb + + + +
+ + +
+
+

Impressum

+ +
+

Angaben gemäß § 5 TMG

+
+ Reifen Späth – Kfz-Meisterbetrieb
+ Inhaber: Daniel Späth
+ Sandizeller Straße 10C
+ 86571 Langenmosen
+ Deutschland +
+

+ Telefon: +49 (0)155 68569616
+ E-Mail: info@reifenspaeth.de
+ Website: www.reifenspaeth.de +

+
+ +
+

Berufsbezeichnung und berufsrechtliche Angaben

+
+
Berufsbezeichnung:
+
Kfz-Meister
+
Zuständige Kammer:
+
Handwerkskammer für München und Oberbayern
+
Verliehen in:
+
Deutschland
+
+
+ +
+

Haftungsausschluss (Disclaimer)

+ +

Haftung für Inhalte

+

+ 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. +

+ +

Haftung für Links

+

+ 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. +

+ +

Urheberrecht

+

+ 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. +

+
+ +
+

Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV

+
+ Daniel Späth
+ Sandizeller Straße 10C
+ 86571 Langenmosen +
+
+
+ + +
+
diff --git a/Lagerbestand/Components/Pages/Impressum.razor.css b/Lagerbestand/Components/Pages/Impressum.razor.css new file mode 100644 index 0000000..ee6e949 --- /dev/null +++ b/Lagerbestand/Components/Pages/Impressum.razor.css @@ -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); + } +} diff --git a/Lagerbestand/Components/Pages/Login.razor b/Lagerbestand/Components/Pages/Login.razor new file mode 100644 index 0000000..78dc5b6 --- /dev/null +++ b/Lagerbestand/Components/Pages/Login.razor @@ -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 + +Login - Reifen Späth Lagerverwaltung + + + + + +@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; + } +} diff --git a/Lagerbestand/Components/Pages/Login.razor.css b/Lagerbestand/Components/Pages/Login.razor.css new file mode 100644 index 0000000..151cdb4 --- /dev/null +++ b/Lagerbestand/Components/Pages/Login.razor.css @@ -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); +} diff --git a/Lagerbestand/Components/Pages/Status.razor b/Lagerbestand/Components/Pages/Status.razor new file mode 100644 index 0000000..ff654bf --- /dev/null +++ b/Lagerbestand/Components/Pages/Status.razor @@ -0,0 +1,1544 @@ +@page "/status" +@implements IAsyncDisposable +@using Lagerbestand.Models +@using Lagerbestand.Components.Shared +@inject IConfiguration Configuration +@inject IAuthService AuthService +@inject IInventoryService InventoryService +@inject ICatalogService CatalogService +@inject ISiteSettingsService SiteSettingsService +@inject IFileStorageService FileStorageService +@inject ILexwareSyncService LexwareSyncService +@inject UserSessionService UserSession +@inject NavigationManager Navigation +@inject IJSRuntime JS + +System Status - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsAdmin) +{ + +} +else +{ +
+
+
+

System Status

+

Entwickler-Informationen & Diagnose

+
+ +
+ + @if (isLoading) + { +
+
+

Lade Systemstatus...

+
+ } + else + { +
+ +
+
+
+ + + + + +

Anwendung

+
+ + + Online + +
+
+
+ Name + Lagerbestand +
+
+ Version + 1.0.0 +
+
+ Framework + .NET @Environment.Version +
+
+ Umgebung + @environment +
+
+ Server-Zeit + @DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss") +
+
+ Uptime + @GetUptime() +
+
+
+ + +
+
+
+ + + + + +

Datenbank

+
+ + + @if (dbConnected && (!storageSettings.IsEnabled || storageSettings.IsPathAccessible()) && (!lexwareSettings.IsEnabled || lexwareSettings.IsConfigured)) + { + @:Verbunden + } + else if (!dbConnected) + { + @:Fehler + } + else if (storageSettings.IsEnabled && !storageSettings.IsPathAccessible()) + { + @:NFS Fehler + } + else if (lexwareSettings.IsEnabled && !lexwareSettings.IsConfigured) + { + @:API Fehler + } + else + { + @:Teilweise + } + +
+
+
+ Server + @dbServer +
+
+ Datenbank + @dbName +
+
+ Status + + @(dbConnected ? "Verbunden" : dbError) + +
+
+
+ NFS/Dateispeicher + + @if (storageSettings.IsEnabled) + { + Aktiviert + } + else + { + Deaktiviert + } + +
+ @if (storageSettings.IsEnabled) + { +
+ NFS Pfad + @(string.IsNullOrWhiteSpace(storageSettings.BasePath) ? "–" : storageSettings.BasePath) +
+
+ NFS Status + + @(storageSettings.IsPathAccessible() ? "Erreichbar" : "Nicht erreichbar") + +
+
+ Max. Dateigröße + @(storageSettings.MaxFileSizeBytes / (1024 * 1024)) MB +
+ } +
+
+ Lexware API + + @if (lexwareSettings.IsEnabled && lexwareSettings.IsConfigured) + { + Verbunden + } + else if (lexwareSettings.IsEnabled && !lexwareSettings.IsConfigured) + { + Nicht konfiguriert + } + else + { + Deaktiviert + } + +
+ @if (lexwareSettings.IsEnabled) + { +
+ Letzte Synchronisierung + + @if (lexwareSettings.LastSyncAt.HasValue) + { + @lexwareSettings.LastSyncAt.Value.ToLocalTime().ToString("dd.MM.yyyy HH:mm") + } + else + { + Noch nie + } + +
+ @if (!string.IsNullOrEmpty(lexwareSettings.LastSyncResult)) + { +
+ Letztes Ergebnis + @lexwareSettings.LastSyncResult +
+ } + } +
+
+ + +
+
+
+ + + + + +

Statistiken

+
+
+
+
+
+ @userCount + Benutzer +
+
+ @categoryCount + Kategorien +
+
+ @inventoryCount + Artikel +
+
+
+
+ Aktive Benutzer + @activeUserCount +
+
+ Administratoren + @adminCount +
+
+
+
+ + +
+
+
+ + + + +

Aktueller Benutzer

+
+
+
+
+ Benutzername + @UserSession.Username +
+
+ Anzeigename + @UserSession.DisplayName +
+
+ Rolle + @UserSession.RoleName +
+
+ User-ID + @UserSession.UserId +
+
+ Letzter Login + @(UserSession.CurrentUser?.LastLoginAt?.ToLocalTime().ToString("dd.MM.yyyy HH:mm") ?? "-") +
+
+
+ + +
+
+
+ + + + + + + + + + + + +

System

+
+
+
+
+ Betriebssystem + @Environment.OSVersion.Platform +
+
+ OS Version + @Environment.OSVersion.Version +
+
+ Prozessoren + @Environment.ProcessorCount Kerne +
+
+ Architektur + @(Environment.Is64BitProcess ? "64-Bit" : "32-Bit") +
+
+ Speicher (App) + @GetMemoryUsage() +
+
+ Working Set + @GetWorkingSetMemory() +
+
+
+ + +
+
+
+ + + + +

Konfiguration

+
+
+
+
+ Admin-User + @adminUsername +
+
+ Base URL + @Navigation.BaseUri +
+
+ Render Mode + Interactive Server +
+
+ Culture + @System.Globalization.CultureInfo.CurrentCulture.Name +
+
+
+
+ + +
+

Schnellaktionen

+
+ + + + + + + + + Benutzerverwaltung + + + + + + +
+
+ + @* Banner Settings Panel *@ + @if (showBannerSettings) + { + + } + + @* File Storage Settings Panel *@ + @if (showStorageSettings) + { + + } + + @* Lexware API Settings Panel *@ + @if (showLexwareSettings) + { + + } + + @* Two-Factor & SMTP Settings Panel *@ + @if (showTwoFactorSettings) + { + + } + + @* Session Settings Panel *@ + @if (showSessionSettings) + { + + } + + @if (!string.IsNullOrEmpty(actionMessage)) + { +
+ @actionMessage +
+ } + + + } +
+} + +@code { +private static readonly DateTime StartTime = DateTime.Now; + +private bool isLoading = true; +private bool isPerformingAction; +private string environment = "Unknown"; +private string dbServer = ""; +private string dbName = ""; +private bool dbConnected; +private string? dbError; +private int userCount; +private int activeUserCount; +private int adminCount; +private int categoryCount; +private int inventoryCount; +private string adminUsername = ""; +private DateTime lastRefresh = DateTime.Now; +private string? actionMessage; +private string actionMessageType = "success"; + +// Banner settings +private bool showBannerSettings; +private bool isSavingBanner; +private PromoBannerSettings bannerSettings = new(); +private string? bannerMessage; +private string bannerMessageType = "success"; + +// File Storage settings +private bool showStorageSettings; +private bool isSavingStorage; +private bool isValidatingStorage; +private FileStorageSettings storageSettings = new(); +private string? storageMessage; +private string storageMessageType = "success"; + +// Lexware API settings +private bool showLexwareSettings; +private bool isSavingLexware; +private bool isTestingLexware; +private LexwareApiSettings lexwareSettings = new(); +private string? lexwareMessage; +private string lexwareMessageType = "success"; + +// Two-Factor settings +private bool showTwoFactorSettings; +private bool isSavingTwoFactor; +private TwoFactorSettings twoFactorSettings = new(); +private string? twoFactorMessage; +private string twoFactorMessageType = "success"; + +// SMTP settings +private bool isSavingSmtp; +private SmtpSettings smtpSettings = new(); +private string? smtpMessage; +private string smtpMessageType = "success"; + +// Session settings (idle lock + circuit retention) +private bool showSessionSettings; +private bool isSavingSession; +private SessionSettings sessionSettings = new(); +private string? sessionMessage; +private string sessionMessageType = "success"; + +protected override async Task OnInitializedAsync() +{ + if (UserSession.IsAdmin) + { + await LoadStatus(); + await LoadBannerSettings(); + await LoadStorageSettings(); + await LoadLexwareSettings(); + await LoadTwoFactorSettings(); + await LoadSmtpSettings(); + await LoadSessionSettings(); + } +} + +private DotNetObjectReference? _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 LoadStatus(); + await InvokeAsync(StateHasChanged); +} + +public async ValueTask DisposeAsync() +{ + await VisibilityRefresh.UnregisterAsync(JS); + _visibilityRef?.Dispose(); +} + +private async Task LoadSessionSettings() +{ + try + { + sessionSettings = await SiteSettingsService.GetSessionSettingsAsync(); + } + catch (Exception ex) + { + sessionMessage = $"Fehler beim Laden: {ex.Message}"; + sessionMessageType = "error"; + } +} + +private void ToggleSessionSettings() +{ + showSessionSettings = !showSessionSettings; + sessionMessage = null; +} + +private async Task SaveSessionSettings() +{ + isSavingSession = true; + sessionMessage = null; + StateHasChanged(); + + try + { + await SiteSettingsService.SaveSessionSettingsAsync(sessionSettings); + sessionMessage = "Sitzungs-Einstellungen gespeichert! Die Verbindungs-Aufbewahrung gilt erst nach einem Neustart der Anwendung."; + sessionMessageType = "success"; + + // Apply idle timeout immediately in the current tab. + try + { + await JS.InvokeVoidAsync("appInterop.startIdleWatcher", sessionSettings.GetSafeIdleTimeoutMinutes()); + } + catch { /* non-fatal */ } + } + catch (Exception ex) + { + sessionMessage = $"Fehler: {ex.Message}"; + sessionMessageType = "error"; + } + finally + { + isSavingSession = false; + } +} + +private async Task LoadBannerSettings() +{ + try + { + bannerSettings = await SiteSettingsService.GetPromoBannerSettingsAsync(); + } + catch (Exception ex) + { + bannerMessage = $"Fehler beim Laden: {ex.Message}"; + bannerMessageType = "error"; + } +} + +private void ToggleBannerSettings() +{ + showBannerSettings = !showBannerSettings; + bannerMessage = null; +} + +private async Task LoadStorageSettings() +{ + try + { + storageSettings = await FileStorageService.GetSettingsAsync(); + } + catch (Exception ex) + { + storageMessage = $"Fehler beim Laden: {ex.Message}"; + storageMessageType = "error"; + } +} + +private void ToggleStorageSettings() +{ + showStorageSettings = !showStorageSettings; + storageMessage = null; +} + +private void OnMaxFileSizeChanged(ChangeEventArgs e) +{ + if (long.TryParse(e.Value?.ToString(), out var mb)) + { + storageSettings.MaxFileSizeBytes = mb * 1024 * 1024; + } +} + +private async Task ValidateStoragePath() +{ + isValidatingStorage = true; + storageMessage = null; + StateHasChanged(); + + try + { + var (isValid, errorMessage) = await FileStorageService.ValidateStoragePathAsync(storageSettings.BasePath); + if (isValid) + { + storageMessage = "Pfad ist gültig und beschreibbar!"; + storageMessageType = "success"; + } + else + { + storageMessage = errorMessage ?? "Pfad ist ungültig."; + storageMessageType = "error"; + } + } + catch (Exception ex) + { + storageMessage = $"Fehler: {ex.Message}"; + storageMessageType = "error"; + } + finally + { + isValidatingStorage = false; + } +} + +private async Task SaveStorageSettings() +{ + isSavingStorage = true; + storageMessage = null; + StateHasChanged(); + + try + { + await FileStorageService.SaveSettingsAsync(storageSettings); + storageMessage = "Dateispeicher-Einstellungen gespeichert!"; + storageMessageType = "success"; + } + catch (Exception ex) + { + storageMessage = $"Fehler: {ex.Message}"; + storageMessageType = "error"; + } + finally + { + isSavingStorage = false; + } +} + +private async Task SaveBannerSettings() +{ + isSavingBanner = true; + bannerMessage = null; + StateHasChanged(); + + try + { + await SiteSettingsService.SavePromoBannerSettingsAsync(bannerSettings); + bannerMessage = "Banner-Einstellungen gespeichert!"; + bannerMessageType = "success"; + } + catch (Exception ex) + { + bannerMessage = $"Fehler: {ex.Message}"; + bannerMessageType = "error"; + } + finally + { + isSavingBanner = false; + } + } + + private async Task LoadLexwareSettings() + { + try + { + lexwareSettings = await SiteSettingsService.GetLexwareApiSettingsAsync(); + } + catch (Exception ex) + { + lexwareMessage = $"Fehler beim Laden: {ex.Message}"; + lexwareMessageType = "error"; + } + } + + private void ToggleLexwareSettings() + { + showLexwareSettings = !showLexwareSettings; + lexwareMessage = null; + } + + private async Task TestLexwareConnection() + { + isTestingLexware = true; + lexwareMessage = null; + StateHasChanged(); + + try + { + var (success, message) = await LexwareSyncService.TestConnectionAsync(lexwareSettings); + lexwareMessage = message; + lexwareMessageType = success ? "success" : "error"; + } + catch (Exception ex) + { + lexwareMessage = $"Fehler: {ex.Message}"; + lexwareMessageType = "error"; + } + finally + { + isTestingLexware = false; + } + } + + private async Task SaveLexwareSettings() + { + isSavingLexware = true; + lexwareMessage = null; + StateHasChanged(); + + try + { + await SiteSettingsService.SaveLexwareApiSettingsAsync(lexwareSettings); + lexwareMessage = "Lexware API-Einstellungen gespeichert!"; + lexwareMessageType = "success"; + } + catch (Exception ex) + { + lexwareMessage = $"Fehler: {ex.Message}"; + lexwareMessageType = "error"; + } + finally + { + isSavingLexware = false; + } + } + + private async Task LoadTwoFactorSettings() + { + try + { + twoFactorSettings = await SiteSettingsService.GetTwoFactorSettingsAsync(); + } + catch (Exception ex) + { + twoFactorMessage = $"Fehler beim Laden: {ex.Message}"; + twoFactorMessageType = "error"; + } + } + + private async Task LoadSmtpSettings() + { + try + { + smtpSettings = await SiteSettingsService.GetSmtpSettingsAsync(); + } + catch (Exception ex) + { + smtpMessage = $"Fehler beim Laden: {ex.Message}"; + smtpMessageType = "error"; + } + } + + private void ToggleTwoFactorSettings() + { + showTwoFactorSettings = !showTwoFactorSettings; + twoFactorMessage = null; + smtpMessage = null; + } + + private async Task SaveTwoFactorSettings() + { + isSavingTwoFactor = true; + twoFactorMessage = null; + StateHasChanged(); + + try + { + await SiteSettingsService.SaveTwoFactorSettingsAsync(twoFactorSettings); + twoFactorMessage = "2FA-Einstellungen gespeichert!"; + twoFactorMessageType = "success"; + } + catch (Exception ex) + { + twoFactorMessage = $"Fehler: {ex.Message}"; + twoFactorMessageType = "error"; + } + finally + { + isSavingTwoFactor = false; + } + } + + private async Task SaveSmtpSettings() + { + isSavingSmtp = true; + smtpMessage = null; + StateHasChanged(); + + try + { + await SiteSettingsService.SaveSmtpSettingsAsync(smtpSettings); + smtpMessage = "SMTP-Einstellungen gespeichert!"; + smtpMessageType = "success"; + } + catch (Exception ex) + { + smtpMessage = $"Fehler: {ex.Message}"; + smtpMessageType = "error"; + } + finally + { + isSavingSmtp = false; + } + } + + private async Task RefreshStatus() + { + isLoading = true; + actionMessage = null; + StateHasChanged(); + await Task.Delay(300); + await LoadStatus(); + } + + private async Task LoadStatus() + { + try + { + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; + + var connectionString = Configuration.GetConnectionString("DefaultConnection") ?? ""; + ParseConnectionString(connectionString); + + adminUsername = Configuration["AdminUser:Username"] ?? "admin"; + + try + { + var users = await AuthService.GetAllUsersAsync(); + userCount = users.Count; + activeUserCount = users.Count(u => u.IsActive); + adminCount = users.Count(u => u.Role == UserRole.Admin && u.IsActive); + + var categories = await CatalogService.GetAllAsync(); + categoryCount = categories.Count; + + var items = await InventoryService.GetAllAsync(); + inventoryCount = items.Count; + + dbConnected = true; + } + catch (Exception ex) + { + dbConnected = false; + dbError = ex.Message; + } + + lastRefresh = DateTime.Now; + } + catch (Exception ex) + { + dbError = ex.Message; + } + finally + { + isLoading = false; + } + } + + private void ParseConnectionString(string connectionString) + { + foreach (var part in connectionString.Split(';')) + { + var kv = part.Split('=', 2); + if (kv.Length != 2) continue; + + var key = kv[0].Trim().ToLowerInvariant(); + var value = kv[1].Trim(); + + if (key == "server") + dbServer = value; + else if (key == "database") + dbName = value; + } + } + + private async Task ForceGarbageCollection() + { + isPerformingAction = true; + actionMessage = null; + StateHasChanged(); + + try + { + var beforeMemory = GC.GetTotalMemory(false); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + await Task.Delay(500); + + var afterMemory = GC.GetTotalMemory(true); + var freedBytes = beforeMemory - afterMemory; + var freedMb = freedBytes / (1024.0 * 1024); + + actionMessage = $"Speicherbereinigung abgeschlossen. {freedMb:F2} MB freigegeben."; + actionMessageType = "success"; + } + catch (Exception ex) + { + actionMessage = $"Fehler: {ex.Message}"; + actionMessageType = "error"; + } + finally + { + isPerformingAction = false; + } + } + + private static string GetUptime() + { + var uptime = DateTime.Now - StartTime; + + if (uptime.TotalDays >= 1) + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalHours >= 1) + return $"{(int)uptime.TotalHours}h {uptime.Minutes}m"; + return $"{uptime.Minutes}m {uptime.Seconds}s"; + } + + private static string GetMemoryUsage() + { + var bytes = GC.GetTotalMemory(false); + return FormatBytes(bytes); + } + + private static string GetWorkingSetMemory() + { + try + { + var process = System.Diagnostics.Process.GetCurrentProcess(); + return FormatBytes(process.WorkingSet64); + } + catch + { + return "N/A"; + } + } + + private static string FormatBytes(long bytes) + { + return bytes switch + { + >= 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024 * 1024):F2} GB", + >= 1024 * 1024 => $"{bytes / (1024.0 * 1024):F2} MB", + >= 1024 => $"{bytes / 1024.0:F2} KB", + _ => $"{bytes} B" + }; + } +} diff --git a/Lagerbestand/Components/Pages/Status.razor.css b/Lagerbestand/Components/Pages/Status.razor.css new file mode 100644 index 0000000..490f138 --- /dev/null +++ b/Lagerbestand/Components/Pages/Status.razor.css @@ -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); +} diff --git a/Lagerbestand/Components/Pages/StorageLocations.razor b/Lagerbestand/Components/Pages/StorageLocations.razor new file mode 100644 index 0000000..f66eaa3 --- /dev/null +++ b/Lagerbestand/Components/Pages/StorageLocations.razor @@ -0,0 +1,1090 @@ +@page "/storage-locations" +@using Lagerbestand.Components.Shared +@inject IStorageLocationService StorageLocationService +@inject ILocationCodeService LocationCodeService +@inject ITireStackService TireStackService +@inject UserSessionService UserSession + +Lagerplätze - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsAdmin) +{ + +} +else +{ +
+ + +
+
+ @allLocations.Count + Gesamt +
+
+ @allLocations.Count(l => !l.IsOccupied) + Frei +
+
+ @allLocations.Count(l => l.IsOccupied) + Belegt +
+
+ @allLocations.Count(l => l.Area == AreaType.Lager) + � Lager +
+
+ @allLocations.Count(l => l.Area == AreaType.Werkstatt) + 🔧 Werkstatt +
+
+ +
+
+ Gebäude +
+ + + +
+
+ +
+ Reihe +
+ +
+
+ +
+ Status +
+ + + +
+
+ +
+ Suche + +
+
+ + @* Loading State *@ + @if (isLoading) + { +
+
+ Lade Lagerplätze... +
+ } + else if (filteredLocations.Count == 0) + { +
+
📦
+

Keine Lagerplätze

+

@(string.IsNullOrEmpty(searchTerm) ? "Erstellen Sie Lagerplätze für Ihre Reifenlagerung." : "Keine Ergebnisse für Ihre Suche.")

+ @if (string.IsNullOrEmpty(searchTerm)) + { + + } +
+ } + else if (viewMode == "grid") + { + @* Grid View - grouped by area and row *@ + @foreach (var areaGroup in filteredLocations.GroupBy(l => l.Area).OrderBy(g => (int)g.Key)) + { +
+

+ @(areaGroup.Key == AreaType.Lager ? "�" : "🔧") @areaGroup.Key.GetDisplayName() +

+ + @foreach (var rowGroup in areaGroup.GroupBy(l => l.Row).OrderBy(g => g.Key)) + { + var rowKey = $"{areaGroup.Key}-{rowGroup.Key}"; + var isCollapsed = collapsedRows.Contains(rowKey); + var occupiedCount = rowGroup.Count(l => l.IsOccupied); + var totalCount = rowGroup.Count(); +
+ + @if (!isCollapsed) + { + @foreach (var posGroup in rowGroup.GroupBy(l => l.Position).OrderBy(g => (int)g.Key)) + { +

@posGroup.Key.GetDisplayName()

+
+ @foreach (var location in posGroup.OrderBy(l => l.SlotNumber)) + { + var storedTireStack = location.OccupiedTireStack; +
+
+ @location.Code + @location.SlotNumber +
+ @if (location.IsOccupied && storedTireStack != null) + { +
+ + @(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager") + + + @(storedTireStack.Season == TireSeason.Sommer ? "☀�" : storedTireStack.Season == TireSeason.Winter ? "��" : "🔄") + +
+ } + else + { +
+ Frei +
+ } + @if (!location.IsAvailable) + { +
Gesperrt
+ } +
+ } +
+ } + } +
+ } +
+ } + } + else + { + @* Table View *@ +
+ + + + + + + + + + + + + + + + + + @foreach (var location in filteredLocations) + { + var storedTireStack = location.OccupiedTireStack; + + + + + + + + + + + + + + } + +
CodeGebäudeReiheSeite/EbenePlatzKunde/InhaltReifenSaisonEingelagert
+ + + @location.Code + + + @(location.Area == AreaType.Lager ? "�" : "🔧") @location.Area.GetDisplayName() + + @location.Row@location.Position.GetShortName()@location.SlotNumber + @if (storedTireStack != null) + { + @if (storedTireStack.Customer != null) + { + @storedTireStack.Customer.FullName + @if (storedTireStack.Vehicle != null) + { + @storedTireStack.Vehicle.ShortName + } + } + else + { + 📦 @(storedTireStack.CatalogItem?.Name ?? "Lager") + } + } + else + { + - + } + + @if (storedTireStack != null) + { + 1 (@TireStack.TiresPerStack) + @if (!string.IsNullOrEmpty(storedTireStack.TireSize)) + { + @storedTireStack.TireSize + } + } + else + { + - + } + + @if (storedTireStack != null) + { + + @(storedTireStack.Season == TireSeason.Sommer ? "☀�" : storedTireStack.Season == TireSeason.Winter ? "��" : "🔄") + + } + else + { + - + } + + @if (location.OccupiedSince.HasValue) + { + @location.OccupiedSince.Value.ToLocalTime().ToString("dd.MM.yy") + } + else + { + - + } + + +
+
+ } + + @* Batch Create Section *@ +
+ + + @if (showBatchCreate) + { +
+

Erstellen Sie mehrere Lagerplätze auf einmal – pro Platz werden alle 4 Positionen (links/rechts × oben/unten) angelegt.

+ +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + - + +
+
+
+ +
+ Vorschau: + + @LocationCodeService.GenerateLocationCode(batchArea, batchRow, ShelfPosition.LinksOben, batchSlotStart) ... @LocationCodeService.GenerateLocationCode(batchArea, batchRow, ShelfPosition.RechtsUnten, batchSlotEnd) + +
+ +
+
+ @batchCount Plätze + = @(batchCount * TireStack.TiresPerStack) Reifen Kapazität +
+ +
+ + @if (!string.IsNullOrEmpty(batchError)) + { +
@batchError
+ } +
+
+ } +
+ + @* Location Details Modal *@ + @if (selectedLocation != null) + { + + + } + + @* Delete Confirmation Modal *@ + @if (showDeleteConfirm && locationToDelete != null) + { + +
+ +
+ } + + @* Add/Edit Location Modal - Improved UI/UX *@ + @if (showModal) + { + + + } +
+} + +@code { + // Status filter constants + private const string StatusAvailable = "available"; + private const string StatusOccupied = "occupied"; + + private List allLocations = new(); + private List filteredLocations = new(); + private AreaType? selectedArea; + private string? selectedRow; + private string? statusFilter; + private string searchTerm = ""; + private string viewMode = "grid"; + private readonly HashSet collapsedRows = new(); + private bool isLoading = true; + private bool showBatchCreate = false; + + // Details modal + private StorageLocation? selectedLocation; + private string? detailsError; + + // Delete confirmation + private bool showDeleteConfirm; + private StorageLocation? locationToDelete; + private bool isDeleting; + + // Add/Edit modal + private bool showModal; + private bool isEditing; + private Guid editId; + private AreaType editArea = AreaType.Lager; + private int editRow = 1; + private ShelfPosition editPosition = ShelfPosition.LinksOben; + private int editSlot = 1; + private string editCode = ""; + private string? editNotes; + private string? errorMessage; + private bool isSaving; + private bool isFormValid; + private bool isLocationAvailable; + private bool showSuccessMessage; + private string? successMessage; + private LocationCodeInput? locationCodeInputRef; + + private void ToggleRow(string rowKey) + { + if (!collapsedRows.Add(rowKey)) + { + collapsedRows.Remove(rowKey); + } + } + + // Batch creation + private AreaType batchArea = AreaType.Lager; + private int batchRow = 1; + private int batchSlotStart = 1; + private int batchSlotEnd = 10; + private bool isCreatingBatch; + private string? batchError; + + private bool CanSave => !string.IsNullOrWhiteSpace(editCode) && + LocationCodeService.ValidateLocationCode(editCode).IsValid && + (isEditing || isLocationAvailable); + + private bool CanCreateBatch => + batchSlotEnd >= batchSlotStart && + batchSlotStart >= StorageLocation.MinSlot && + batchSlotEnd <= StorageLocation.MaxSlot; + + private int batchCount => CanCreateBatch ? (batchSlotEnd - batchSlotStart + 1) * ShelfPositionExtensions.All.Length : 0; + + private void OnBatchAreaChanged() + { + if (batchRow > batchArea.GetMaxRow()) + batchRow = batchArea.GetMaxRow(); + } + + protected override async Task OnInitializedAsync() + { + if (UserSession.IsAdmin) + { + await LoadLocations(); + } + } + + private void HandleKeyDown(KeyboardEventArgs e) + { + // Close modals on Escape key + if (e.Key == "Escape") + { + if (showModal) + { + CloseModal(); + } + else if (showDeleteConfirm) + { + CancelDelete(); + } + else if (selectedLocation != null) + { + CloseDetails(); + } + } + } + + private void HandleModalBackdropClick() + { + // Only close if form is empty (not dirty) + if (!IsFormDirty()) + { + CloseModal(); + } + } + + private bool IsFormDirty() + { + if (isEditing) return true; // Always confirm when editing + return !string.IsNullOrEmpty(editNotes) || editSlot != 1; + } + + private void OnFormValidationChanged(bool isValid) + { + isFormValid = isValid; + StateHasChanged(); + } + + private void OnAvailabilityChanged(bool available) + { + isLocationAvailable = available; + StateHasChanged(); + } + + private async Task LoadLocations() + { + isLoading = true; + StateHasChanged(); + + try + { + allLocations = await StorageLocationService.GetAllAsync(); + ApplyFilter(); + } + finally + { + isLoading = false; + } + } + + private void SetGridView() => viewMode = "grid"; + private void SetTableView() => viewMode = "table"; + private void ToggleBatchCreate() => showBatchCreate = !showBatchCreate; + + private void FilterByArea(AreaType? area) + { + selectedArea = area; + ApplyFilter(); + } + + private void FilterByStatus(string? status) + { + statusFilter = status; + ApplyFilter(); + } + + private void FilterByStatusAvailable() => FilterByStatus(StatusAvailable); + private void FilterByStatusOccupied() => FilterByStatus(StatusOccupied); + + private void ApplyFilter() + { + var query = allLocations.AsEnumerable(); + + if (selectedArea.HasValue) + { + query = query.Where(l => l.Area == selectedArea.Value); + } + + if (!string.IsNullOrEmpty(selectedRow) && int.TryParse(selectedRow, out var row)) + { + query = query.Where(l => l.Row == row); + } + + if (statusFilter == StatusAvailable) + { + query = query.Where(l => !l.IsOccupied && l.IsAvailable); + } + else if (statusFilter == StatusOccupied) + { + query = query.Where(l => l.IsOccupied); + } + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var term = searchTerm.ToLower().Trim(); + query = query.Where(l => + l.Code.ToLower().Contains(term) || + l.Area.GetDisplayName().ToLower().Contains(term) || + (l.OccupiedTireStack?.Customer?.FullName?.ToLower().Contains(term) ?? false) || + (l.OccupiedTireStack?.Vehicle?.Make?.ToLower().Contains(term) ?? false) || + (l.OccupiedTireStack?.Vehicle?.Model?.ToLower().Contains(term) ?? false) + ); + } + + filteredLocations = query + .OrderBy(l => l.Area) + .ThenBy(l => l.Row) + .ThenBy(l => l.Position) + .ThenBy(l => l.SlotNumber) + .ToList(); + } + + private async Task ShowLocationDetails(StorageLocation location) + { + detailsError = null; + selectedLocation = await StorageLocationService.GetByIdAsync(location.Id); + } + + private void CloseDetails() + { + selectedLocation = null; + detailsError = null; + } + + private async Task ShowAddLocation() + { + // Find next available location + var next = await LocationCodeService.GetNextAvailableSlotAsync(AreaType.Lager, 1); + + editId = Guid.Empty; + editArea = next?.Area ?? AreaType.Lager; + editRow = next?.Row ?? 1; + editPosition = next?.Position ?? ShelfPosition.LinksOben; + editSlot = next?.SlotNumber ?? 1; + editCode = LocationCodeService.GenerateLocationCode(editArea, editRow, editPosition, editSlot); + editNotes = null; + isEditing = false; + errorMessage = null; + isSaving = false; + showSuccessMessage = false; + isLocationAvailable = true; + showModal = true; + } + + private void EditLocation() + { + if (selectedLocation == null) return; + + editId = selectedLocation.Id; + editArea = selectedLocation.Area; + editRow = selectedLocation.Row; + editPosition = selectedLocation.Position; + editSlot = selectedLocation.SlotNumber; + editCode = selectedLocation.Code; + editNotes = selectedLocation.Notes; + isEditing = true; + errorMessage = null; + isSaving = false; + showSuccessMessage = false; + selectedLocation = null; + showModal = true; + } + + private void CloseModal() + { + showModal = false; + errorMessage = null; + showSuccessMessage = false; + isSaving = false; + } + + private async Task SaveLocation() + { + if (!CanSave || isSaving) return; + + // Validate form + if (locationCodeInputRef != null && !locationCodeInputRef.ValidateAll()) + { + return; + } + + isSaving = true; + errorMessage = null; + StateHasChanged(); + + try + { + if (isEditing) + { + var existing = allLocations.First(l => l.Id == editId); + existing.Area = editArea; + existing.Row = editRow; + existing.Position = editPosition; + existing.SlotNumber = editSlot; + existing.Notes = editNotes; + existing.GenerateCode(); + + await StorageLocationService.UpdateAsync(existing); + + // Show success and close + ApplyFilter(); + CloseModal(); + } + else + { + var location = new StorageLocation + { + Area = editArea, + Row = editRow, + Position = editPosition, + SlotNumber = editSlot, + Notes = editNotes + }; + location.GenerateCode(); + + var created = await StorageLocationService.CreateAsync(location); + allLocations.Add(created); + + // Show success message + successMessage = $"Stellplatz {created.Code} erfolgreich angelegt"; + showSuccessMessage = true; + StateHasChanged(); + + // Auto-close after delay + await Task.Delay(1500); + ApplyFilter(); + CloseModal(); + } + } + catch (Exception ex) + { + errorMessage = ex.Message.Contains("existiert") + ? "Dieser Stellplatz ist bereits belegt" + : $"Fehler beim Anlegen des Stellplatzes: {ex.Message}"; + } + finally + { + isSaving = false; + } + } + + private async Task ToggleAvailability(StorageLocation location) + { + location.IsAvailable = !location.IsAvailable; + location.UpdatedAt = DateTime.UtcNow; + await StorageLocationService.UpdateAsync(location); + selectedLocation = null; + await LoadLocations(); + } + + private void ConfirmDeleteLocation(StorageLocation location) + { + locationToDelete = location; + showDeleteConfirm = true; + selectedLocation = null; + } + + private void CancelDelete() + { + showDeleteConfirm = false; + locationToDelete = null; + } + + private async Task DeleteLocation() + { + if (locationToDelete == null) return; + + isDeleting = true; + StateHasChanged(); + + try + { + var success = await StorageLocationService.DeleteAsync(locationToDelete.Id); + if (success) + { + allLocations.RemoveAll(l => l.Id == locationToDelete.Id); + ApplyFilter(); + CancelDelete(); + } + else + { + detailsError = "Lagerplatz konnte nicht gelöscht werden."; + } + } + catch (Exception ex) + { + detailsError = $"Fehler beim Löschen: {ex.Message}"; + } + finally + { + isDeleting = false; + } + } + + private async Task CreateBatchLocations() + { + if (!CanCreateBatch) return; + + isCreatingBatch = true; + batchError = null; + StateHasChanged(); + + try + { + var created = await StorageLocationService.CreateBatchAsync( + batchArea, batchRow, batchSlotStart, batchSlotEnd); + + await LoadLocations(); + + var skipped = batchCount - created.Count; + + if (created.Count > 0) + { + batchError = $"✓ {created.Count} Plätze erstellt" + (skipped > 0 ? $", {skipped} übersprungen" : ""); + } + else if (skipped > 0) + { + batchError = $"Alle {skipped} Plätze existieren bereits"; + } + + // Reset to next row + batchSlotStart = 1; + batchSlotEnd = 10; + } + catch (Exception ex) + { + batchError = $"Fehler: {ex.Message}"; + } + finally + { + isCreatingBatch = false; + } + } +} diff --git a/Lagerbestand/Components/Pages/StorageLocations.razor.css b/Lagerbestand/Components/Pages/StorageLocations.razor.css new file mode 100644 index 0000000..a76e481 --- /dev/null +++ b/Lagerbestand/Components/Pages/StorageLocations.razor.css @@ -0,0 +1,1300 @@ +/* ======================================== + Storage Locations Page - Responsive Web Optimized + Uses global CSS variables from app.css + ======================================== */ + +.locations-page { + min-height: 100vh; + min-height: 100dvh; + background: var(--color-bg-gradient); + padding: 0.75rem; + padding-bottom: calc(1rem + var(--safe-area-bottom)); +} + +@media (min-width: 480px) { + .locations-page { + padding: 1rem; + } +} + +@media (min-width: 768px) { + .locations-page { + padding: 1.5rem; + } +} + +@media (min-width: 1024px) { + .locations-page { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + } +} + +/* Access-denied: inherited from app.css (section 19) */ + +/* ======================================== + Page Header - Responsive + ======================================== */ +.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.25rem; + } +} + +.header-left { + display: flex; + flex-direction: column; + gap: 4px; +} + +@media (min-width: 480px) { + .header-left { + flex-direction: row; + align-items: baseline; + gap: 12px; + } +} + +.page-header h1 { + font-family: var(--font-family-heading); + font-size: clamp(1.25rem, 4vw, 1.75rem); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0; +} + +.location-count { + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-muted); +} + +.header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +@media (max-width: 479.98px) { + .header-actions { + width: 100%; + } + + .header-actions .btn { + flex: 1; + justify-content: center; + } +} + +/* ======================================== + View Toggle + ======================================== */ +.view-toggle { + display: flex; + background: var(--surface-elevated); + border-radius: var(--radius-sm); + padding: 3px; + border: 0.5px solid var(--border-default); +} + +.view-btn { + padding: 8px 12px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-tertiary); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); + font-size: 1rem; + min-width: 40px; + min-height: var(--touch-target-sm); +} + +.view-btn:hover { + color: var(--text-secondary); +} + +.view-btn.active { + background: rgba(220, 38, 38, 0.3); + color: var(--text-primary); +} + +/* ======================================== + Statistics Bar - Responsive + ======================================== */ +.stats-bar { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--surface-elevated); + border-radius: var(--radius-lg); + border: 0.5px solid var(--border-light); +} + +@media (min-width: 480px) { + .stats-bar { + display: flex; + gap: 1rem; + padding: 1rem; + margin-bottom: 1.25rem; + overflow-x: auto; + } +} + +@media (min-width: 768px) { + .stats-bar { + gap: 2rem; + } +} + +.stat { + text-align: center; + padding: 0 0.5rem; +} + +@media (min-width: 480px) { + .stat { + flex: 1; + min-width: 80px; + padding: 0 1rem; + border-right: 1px solid var(--border-light); + } + + .stat:last-child { + border-right: none; + } +} + +.stat-value { + display: block; + font-family: var(--font-family-heading); + font-size: clamp(1.25rem, 4vw, 1.75rem); + font-weight: var(--font-weight-bold); + color: var(--text-primary); +} + +.stat.available .stat-value { + color: var(--color-success); +} + +.stat.occupied .stat-value { + color: var(--color-warning); +} + +.stat-label { + font-family: var(--font-family-base); + font-size: clamp(0.65rem, 2vw, 0.75rem); + color: var(--text-tertiary); + text-transform: uppercase; +} + +/* ======================================== + Filter Bar - Responsive + ======================================== */ +.filter-bar { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 1rem; + background: rgba(28, 28, 30, 0.4); + border-radius: var(--radius-lg); + border: 0.5px solid var(--border-light); +} + +@media (min-width: 768px) { + .filter-bar { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-end; + gap: 1.25rem; + margin-bottom: 1.25rem; + } +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.filter-label { + font-family: var(--font-family-base); + font-size: 0.7rem; + font-weight: var(--font-weight-semibold); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.filter-group.search-group { + flex: 1; + min-width: 200px; +} + +@media (max-width: 767.98px) { + .filter-group.search-group { + order: -1; + } +} + +/* Area & Status Filter */ +.area-filter, +.status-filter { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* Row Filter */ +.row-filter { + min-width: 120px; +} + +/* Filter Buttons */ +.filter-btn { + padding: 8px 12px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-family: var(--font-family-base); + font-size: var(--text-xs); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); + white-space: nowrap; + min-height: 40px; +} + +.filter-btn:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); +} + +.filter-btn.active { + background: rgba(220, 38, 38, 0.15); + border-color: var(--border-focus); + color: var(--text-primary); +} + +.filter-btn.lager.active { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.4); + color: var(--color-warning); +} + +.filter-btn.werkstatt.active { + background: rgba(96, 165, 250, 0.15); + border-color: rgba(96, 165, 250, 0.4); + color: var(--color-info-light); +} + +.filter-btn.available.active { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); + color: var(--color-success); +} + +.filter-btn.occupied.active { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.4); + color: var(--color-warning); +} + +/* Search Box */ +.search-box { + width: 100%; +} + +.search-box .form-control { + padding: 10px 14px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-family-base); + font-size: var(--text-base); + width: 100%; +} + +.search-box .form-control:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); +} + +.search-box .form-control::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; +} + +.loading-state { + gap: 12px; + color: var(--text-tertiary); + font-family: var(--font-family-base); +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-default); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner.small { + width: 16px; + height: 16px; + border-width: 2px; +} + +.empty-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.empty-state h3 { + font-family: var(--font-family-base); + font-size: clamp(1rem, 3vw, 1.25rem); + color: var(--text-primary); + margin: 0 0 0.5rem 0; +} + +.empty-state p { + font-family: var(--font-family-base); + color: var(--text-tertiary); + margin: 0 0 1.5rem 0; + font-size: var(--text-sm); +} + +/* ======================================== + Area Section (Grid View) + ======================================== */ +.area-section { + margin-bottom: 1.5rem; + padding: 1rem; + background: rgba(28, 28, 30, 0.4); + border-radius: var(--radius-xl); + border: 0.5px solid var(--border-light); +} + +@media (min-width: 480px) { + .area-section { + padding: 1.25rem; + margin-bottom: 2rem; + } +} + +.area-section.lager { + border-color: rgba(251, 191, 36, 0.2); +} + +.area-section.werkstatt { + border-color: rgba(96, 165, 250, 0.2); +} + +.area-title { + font-family: var(--font-family-base); + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin: 0 0 1rem 0; + padding-bottom: 0.75rem; + border-bottom: 0.5px solid var(--border-default); +} + +.row-section { + margin-bottom: 1rem; +} + +.row-section:last-child { + margin-bottom: 0; +} + +.row-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + background: none; + border: none; + padding: 0.35rem 0; + margin: 0 0 0.75rem 0; + cursor: pointer; + text-align: left; + border-radius: 6px; + transition: background-color 0.15s ease; +} + +.row-header:hover { + background: rgba(255, 255, 255, 0.05); +} + +.row-section.collapsed .row-header { + margin-bottom: 0; +} + +.row-chevron { + font-size: 0.6rem; + color: var(--text-secondary); + width: 1rem; + flex-shrink: 0; +} + +.row-summary { + margin-left: auto; + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-secondary); + white-space: nowrap; +} + +.row-title { + font-family: var(--font-family-base); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@media (min-width: 480px) { + .row-title { + font-size: 0.9rem; + } +} + +/* ======================================== + Location Grid - Responsive + ======================================== */ +.location-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +@media (min-width: 400px) { + .location-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 480px) { + .location-grid { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 10px; + } +} + +@media (min-width: 768px) { + .location-grid { + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + } +} + +/* Location Card */ +.location-card { + background: var(--surface-elevated); + border: 0.5px solid var(--border-default); + border-radius: var(--radius-md); + padding: 10px; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); + position: relative; + min-height: 70px; +} + +.location-card:hover { + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.location-card.available { + border-color: rgba(34, 197, 94, 0.25); +} + +.location-card.available:hover { + border-color: rgba(34, 197, 94, 0.5); +} + +.location-card.occupied { + border-color: rgba(251, 191, 36, 0.25); + background: rgba(251, 191, 36, 0.03); +} + +.location-card.occupied:hover { + border-color: rgba(251, 191, 36, 0.5); +} + +.location-card.blocked { + opacity: 0.6; + border-color: rgba(239, 68, 68, 0.3); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.location-code { + font-family: var(--font-family-heading); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.slot-number { + font-family: var(--font-family-base); + font-size: 0.7rem; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.05); + padding: 2px 6px; + border-radius: 4px; +} + +.card-content { + font-family: var(--font-family-base); +} + +.card-content .customer-name { + display: block; + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-content .tire-info { + display: block; + font-size: var(--text-sm); + margin-top: 2px; +} + +.card-content.empty { + text-align: center; + padding: 6px 0; +} + +.card-content .empty-text { + font-size: var(--text-xs); + color: rgba(34, 197, 94, 0.7); +} + +.blocked-badge { + position: absolute; + top: 4px; + right: 4px; + background: rgba(239, 68, 68, 0.3); + color: var(--color-error-light); + font-size: 0.6rem; + padding: 2px 4px; + border-radius: 4px; + font-family: var(--font-family-base); + text-transform: uppercase; +} + +/* ======================================== + Table View + ======================================== */ +.location-table-wrapper { + position: relative; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + background: var(--surface-elevated); + border-radius: var(--radius-lg); + border: 0.5px solid var(--border-light); + margin-bottom: 1.5rem; +} + +@media (max-width: 767.98px) { + .location-table-wrapper::after { + content: ''; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 30px; + background: linear-gradient(to left, rgba(28, 28, 30, 0.9), transparent); + pointer-events: none; + } +} + +.location-table { + width: 100%; + min-width: 700px; + border-collapse: collapse; + font-family: var(--font-family-base); + font-size: var(--text-sm); +} + +.location-table th { + background: rgba(0, 0, 0, 0.4); + padding: 10px 8px; + text-align: left; + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.5px; + white-space: nowrap; + border-bottom: 1px solid var(--border-light); + position: sticky; + top: 0; + z-index: 1; +} + +.location-table td { + padding: 10px 8px; + border-bottom: 0.5px solid var(--border-light); + color: var(--text-secondary); + vertical-align: middle; +} + +.location-table tbody tr { + cursor: pointer; + transition: background var(--duration-fast) var(--ease-default); +} + +.location-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.location-table tbody tr.available { + background: rgba(34, 197, 94, 0.02); +} + +.location-table tbody tr.occupied { + background: rgba(251, 191, 36, 0.02); +} + +.location-table tbody tr.blocked { + background: rgba(239, 68, 68, 0.03); +} + +/* Table columns */ +.code-mono { + font-family: var(--font-family-heading); + font-size: var(--text-sm); +} + +.area-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); +} + +.area-badge.lager { + background: rgba(251, 191, 36, 0.15); + color: var(--color-warning); +} + +.area-badge.werkstatt { + background: rgba(96, 165, 250, 0.15); + color: var(--color-info-light); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot.available { + background: var(--color-success); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); +} + +.status-dot.occupied { + background: var(--color-warning); + box-shadow: 0 0 6px rgba(251, 191, 36, 0.5); +} + +.status-dot.blocked { + background: var(--color-error); + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); +} + +.empty-dash { + color: var(--text-placeholder); +} + +.btn-icon { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: var(--radius-sm); + width: 28px; + height: 28px; + color: var(--text-tertiary); + cursor: pointer; + font-size: 1rem; + transition: all var(--duration-fast) var(--ease-default); +} + +.btn-icon:hover { + background: rgba(255, 255, 255, 0.2); + color: var(--text-primary); +} + +/* ======================================== + Batch Section + ======================================== */ +.batch-section { + background: var(--surface-elevated); + border: 0.5px solid var(--border-light); + border-radius: var(--radius-lg); + margin-top: 1.5rem; + overflow: hidden; +} + +.batch-toggle { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-secondary); + font-family: var(--font-family-base); + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); + min-height: 48px; +} + +.batch-toggle:hover { + background: rgba(255, 255, 255, 0.03); +} + +.toggle-icon { + font-size: 1.2rem; + color: var(--text-muted); +} + +.batch-content { + padding: 0 16px 16px; +} + +.batch-content p { + font-family: var(--font-family-base); + font-size: var(--text-xs); + color: var(--text-tertiary); + margin: 0 0 1rem 0; +} + +.batch-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.batch-preview { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-radius: var(--radius-sm); + flex-wrap: wrap; +} + +.preview-label { + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-tertiary); +} + +.preview-codes { + font-family: var(--font-family-heading); + font-size: var(--text-sm); + color: var(--color-success-light); +} + +.batch-summary-row { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 12px; + border-top: 0.5px solid var(--border-light); +} + +@media (min-width: 480px) { + .batch-summary-row { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +.summary-info { + display: flex; + align-items: baseline; + gap: 10px; +} + +.summary-count { + font-family: var(--font-family-heading); + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + color: var(--color-success); +} + +.summary-detail { + font-family: var(--font-family-base); + font-size: var(--text-xs); + color: var(--text-tertiary); +} + +/* ======================================== + Forms + ======================================== */ +.form-row { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +@media (min-width: 480px) { + .form-row { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.form-group label { + font-family: var(--font-family-base); + font-size: var(--text-xs); + color: var(--text-secondary); + font-weight: var(--font-weight-medium); + display: flex; + align-items: center; + gap: 4px; +} + +.optional-indicator { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: var(--font-weight-normal); +} + +.range-input { + display: flex; + align-items: center; + gap: 8px; +} + +.range-input span { + color: var(--text-muted); +} + +.range-input .form-control { + width: 60px; +} + +@media (min-width: 480px) { + .range-input .form-control { + width: 70px; + } +} + +.notes-group { + margin-top: 1.25rem; +} + +.field-hint { + font-family: var(--font-family-base); + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; +} + +/* ======================================== + Modal Content Variants (uses global modal system) + ======================================== */ +.modal-content.details-modal { + max-width: 520px; +} + +.modal-content.confirm-modal { + max-width: 400px; +} + +.modal-content.edit-modal { + max-width: 520px; +} + +/* ======================================== + Modal Header Custom Elements + ======================================== */ +.modal-title-wrapper { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modal-subtitle { + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-tertiary); + font-weight: var(--font-weight-normal); +} + +.modal-title-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.code-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(220, 38, 38, 0.15); + border: 1px solid rgba(220, 38, 38, 0.3); + border-radius: var(--radius-sm); +} + +.code-icon { + font-size: 1rem; +} + +.code-text { + font-family: var(--font-family-heading); + font-size: var(--text-lg); + font-weight: var(--font-weight-bold); + color: var(--color-primary); + letter-spacing: 0.1em; +} + +.location-description { + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-tertiary); +} + +/* ======================================== + Modal Footer Custom Elements + ======================================== */ +.footer-spacer { + flex: 1; +} + +@media (max-width: 479.98px) { + .footer-spacer { + display: none; + } +} + +/* ======================================== + Status Banner (Details Modal) + ======================================== */ +.status-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 1rem; +} + +.status-banner.available { + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.status-banner.occupied { + background: rgba(251, 191, 36, 0.12); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.status-banner.blocked { + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.status-banner .status-icon { + font-size: 1.25rem; +} + +.status-banner .status-text { + font-family: var(--font-family-base); + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); +} + +.status-banner.available .status-text { + color: var(--color-success); +} + +.status-banner.occupied .status-text { + color: var(--color-warning); +} + +.status-banner.blocked .status-text { + color: var(--color-error-light); +} + +/* ======================================== + Detail Grid + ======================================== */ +.detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 1rem; +} + +@media (min-width: 480px) { + .detail-grid { + gap: 14px; + margin-bottom: 1.25rem; + } +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.detail-item .label { + font-family: var(--font-family-base); + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detail-item .value { + font-family: var(--font-family-base); + font-size: var(--text-base); + color: var(--text-primary); + display: flex; + align-items: center; + gap: 6px; +} + +.area-icon { + font-size: 1rem; +} + +/* ======================================== + Stored Tires Section + ======================================== */ +.stored-tires { + background: rgba(0, 0, 0, 0.2); + border: 0.5px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 14px; + margin-bottom: 1rem; +} + +.stored-tires h4 { + font-family: var(--font-family-base); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + margin: 0 0 12px 0; +} + +.stored-item { + padding: 0; +} + +.stored-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + flex-wrap: wrap; + gap: 8px; +} + +.stored-customer { + font-family: var(--font-family-base); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + font-size: var(--text-base); +} + +.stored-ticket { + font-family: var(--font-family-heading); + font-size: 0.75rem; + color: var(--color-primary); + padding: 3px 8px; + background: rgba(220, 38, 38, 0.15); + border-radius: 4px; +} + +.stored-details { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.detail-chip { + font-family: var(--font-family-base); + font-size: var(--text-xs); + color: var(--text-secondary); + padding: 3px 8px; + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; +} + +.season-chip.sommer { + background: rgba(251, 191, 36, 0.15); + color: var(--color-warning); +} + +.season-chip.winter { + background: rgba(96, 165, 250, 0.15); + color: var(--color-info-light); +} + +.season-chip.ganzjahres { + background: rgba(34, 197, 94, 0.15); + color: var(--color-success); +} + +.stored-brand { + font-family: var(--font-family-base); + color: var(--text-tertiary); + font-size: var(--text-sm); + margin-bottom: 4px; +} + +.stored-date { + font-family: var(--font-family-base); + color: var(--text-muted); + font-size: var(--text-xs); +} + +/* ======================================== + Notes Section + ======================================== */ +.notes-section { + background: rgba(0, 0, 0, 0.15); + border-radius: var(--radius-sm); + padding: 12px; + margin-bottom: 1rem; +} + +.notes-section .label { + font-family: var(--font-family-base); + font-size: 0.75rem; + color: var(--text-tertiary); + display: block; + margin-bottom: 6px; +} + +.notes-section p { + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0; + line-height: var(--line-height-relaxed); +} + +/* ======================================== + Confirm Modal + ======================================== */ +.confirm-icon { + font-size: 3rem; + text-align: center; + margin-bottom: 1rem; +} + +.confirm-text { + font-family: var(--font-family-base); + font-size: var(--text-base); + color: var(--text-primary); + text-align: center; + margin: 0 0 0.5rem 0; +} + +.confirm-subtext { + font-family: var(--font-family-base); + font-size: var(--text-sm); + color: var(--text-tertiary); + text-align: center; + margin: 0; +} + +/* ======================================== + Success Message + ======================================== */ +.success-message { + animation: slideDown var(--duration-normal) var(--ease-out); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.success-message .success-icon, +.alert .error-icon { + font-size: 1rem; + flex-shrink: 0; +} + +/* Buttons: inherited from app.css (section 5) */ + +/* ======================================== + Reduced Motion + ======================================== */ +/* Reduced motion: inherited from app.css (section 17) */ diff --git a/Lagerbestand/Components/Pages/TireCheckin.razor b/Lagerbestand/Components/Pages/TireCheckin.razor new file mode 100644 index 0000000..d24e0ab --- /dev/null +++ b/Lagerbestand/Components/Pages/TireCheckin.razor @@ -0,0 +1,1666 @@ +@page "/tire-checkin" +@implements IAsyncDisposable +@using Lagerbestand.Components.Shared +@inject ICustomerService CustomerService +@inject IStorageLocationService StorageLocationService +@inject ILocationCodeService LocationCodeService +@inject ITireStackService TireStackService +@inject ICatalogService CatalogService +@inject IFileStorageService FileStorageService +@inject UserSessionService UserSession +@inject IJSRuntime JS + +Reifenstapel Einlagern - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsWorkerOrAdmin) +{ + +} +else +{ +
+ @* Progress Steps *@ + + + @* Step 1: Customer OR Category Selection *@ + @if (currentStep == 1) + { +
+ @* Mode Toggle *@ +
+ + +
+ + @if (!isInventoryMode) + { + @* Customer Selection Mode *@ +

Kunde auswählen

+ + + + @if (isSearching) + { +
+
+ Suche... +
+ } + else if (searchResults.Count > 0) + { +
+ @foreach (var customer in searchResults) + { +
+
+ @customer.FullName + @if (!string.IsNullOrEmpty(customer.Company)) + { + @customer.Company + } + + @customer.CustomerNumber + @if (customer.Vehicles.Any(v => v.IsActive)) + { + @string.Join(", ", customer.Vehicles.Where(v => v.IsActive).Select(v => v.ShortName)) + } + +
+
+ @customer.TireStacks.Count(ts => ts.Status == TireStackStatus.Stored) Stapel +
+
+ } +
+ } + else if (!string.IsNullOrEmpty(customerSearch)) + { +
+

Kein Kunde gefunden

+ +
+ } + + @if (showNewCustomerForm) + { +
+

Neuer Kunde

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+

Fahrzeug

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ } + + @* Vehicle Selection for existing customer *@ + @if (selectedCustomer != null && !showNewCustomerForm) + { +
+

Fahrzeug auswählen

+ @if (customerVehicles.Count == 0) + { +
+

Noch kein Fahrzeug hinterlegt.

+
+ } + else + { +
+ @foreach (var vehicle in customerVehicles) + { +
+
+ @vehicle.DisplayName + @if (vehicle.IsPrimary) + { + Hauptfahrzeug + } +
+
+ @(vehicle.TireStacks?.Count(ts => ts.Status == TireStackStatus.Stored) ?? 0) Stapel eingelagert +
+
+ } +
+ } + + @* Add new vehicle button *@ + + + @if (showAddVehicleForm) + { +
+

Neues Fahrzeug

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ } +
+ } + +
+ +
+ } + else + { + @* Inventory/Category Selection Mode *@ +

Kategorie auswählen

+

Für Neuware/Lieferungen die ins Lager aufgenommen werden sollen.

+ + @if (categories.Count == 0) + { +
+

Keine Kategorien vorhanden

+ +
+ } + else + { +
+ @foreach (var category in categories) + { +
+ @if (category.Type == CatalogItemType.Felgen && !string.IsNullOrEmpty(category.PictureFileName)) + { +
+ @category.Name +
+ } + else + { +
+ @if (category.Type == CatalogItemType.Felgen) + { + 🛞 + } + else + { + @(category.Season == TireSeason.Sommer ? "☀�" : category.Season == TireSeason.Winter ? "��" : "🔄") + } +
+ } +
+ @category.Name + @category.DisplayDetails + + @(category.Type == CatalogItemType.Felgen ? "Felgen" : "Reifen") + +
+
+ } +
+ + + } + + @if (showNewCategoryForm) + { +
+

Neue Kategorie

+ + @* Category Type Toggle *@ +
+ +
+ + +
+
+ +
+ + +
+
+ + @if (newCategory.Type == CatalogItemType.Felgen) + { + + } + else + { + + } +
+ + @if (newCategory.Type == CatalogItemType.Reifen) + { +
+ +
+ + + +
+
+ } + + @if (newCategory.Type == CatalogItemType.Felgen) + { +
+ +
+ @if (categoryImagePreview != null) + { +
+ Vorschau + +
+ } + else + { + + } +
+
+ } + +
+ + +
+
+ } + +
+ +
+ } +
+ } + + @* Step 2: Tire Stack Information *@ + @if (currentStep == 2) + { +
+
+ @if (isInventoryMode) + { + Kategorie: @selectedCategory?.Name + } + else + { + Kunde: @selectedCustomer?.FullName + @if (selectedVehicle != null) + { + 🚗 @selectedVehicle.DisplayName + } + } + +
+ +

@(isFelgenCategory ? "Felgen erfassen" : "Reifenstapel erfassen")

+ +
+ @(isFelgenCategory ? "🔩" : "🛞") + @if (isInventoryMode) + { + + Stückzahl: + @if (useCustomItemCount) + { + @tireStack.ItemCount + } + else + { + @TireStack.TiresPerStack Stück (Standard) + } + + + } + else + { + Ein Stapel = @TireStack.TiresPerStack Reifen + } +
+ + @if (isInventoryMode && useCustomItemCount) + { +
+ +
+ + + +
+
+ } + + @* Tire/Felgen Form *@ +
+ @if (!isFelgenCategory) + { +
+ +
+ + + +
+
+ } + +
+ + @if (isFelgenCategory) + { + + } + else + { + + } +
+ +
+
+ + +
+
+ + +
+
+ + @if (!isFelgenCategory) + { +
+
+ +
+ + +
+
+ @if (tireStack.WithRims) + { +
+ + +
+ } +
+ } + else + { +
+ +
+ + +
+
+ } + + @* Collapsible Details Section *@ +
+ + + @if (showDetailsExpanded) + { +
+ @if (!isFelgenCategory) + { +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ } + else + { +
+ + +
+ } + +
+ + +
+
+ } +
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + +
+ + +
+
+ } + + @* Step 3: Storage Location *@ + @if (currentStep == 3) + { +
+
+ @if (isInventoryMode) + { +
Kategorie: @selectedCategory?.Name
+ } + else + { +
Kunde: @selectedCustomer?.FullName
+ @if (selectedVehicle != null) + { +
Fahrzeug: @selectedVehicle.DisplayName
+ } + } +
@(isFelgenCategory ? "Felgen" : "Reifenstapel"): @(isInventoryMode && useCustomItemCount ? tireStack.ItemCount : TireStack.TiresPerStack) @(isFelgenCategory ? "Felgen" : "Reifen") @(isFelgenCategory ? "" : $"| {tireStack.Season}")
+
+ +

Lagerplatz auswählen

+ +
+ @* Area Filter *@ +
+ + + +
+ + @* Row Filter *@ +
+ +
+ + @* Location Grid *@ + @if (filteredLocations.Count == 0) + { +
+

Keine freien Lagerplätze verfügbar

+ Lagerplätze verwalten +
+ } + else + { + @foreach (var areaGroup in filteredLocations.GroupBy(l => l.Area).OrderBy(g => (int)g.Key)) + { +
+

+ @(areaGroup.Key == AreaType.Lager ? "�" : "🔧") @areaGroup.Key.GetDisplayName() +

+ + @foreach (var rowGroup in areaGroup.GroupBy(l => l.Row).OrderBy(g => g.Key)) + { +
+ Reihe @rowGroup.Key +
+ @foreach (var loc in rowGroup.OrderBy(l => l.Position).ThenBy(l => l.SlotNumber)) + { + + } +
+
+ } +
+ } + } +
+ + @if (selectedLocation != null) + { +
+
�
+
+ +
+ +
+ } + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + +
+ + +
+
+ } + + @* Step 4: Confirmation *@ + @if (currentStep == 4) + { +
+
✓
+

Erfolgreich eingelagert!

+ +
+
+ @createdTireStack?.TicketNumber + @if (isInventoryMode) + { + Lagerbestand + } +
+
+ @if (isInventoryMode) + { +
+ Kategorie: + @selectedCategory?.Name +
+ } + else + { +
+ Kunde: + @selectedCustomer?.FullName +
+
+ Fahrzeug: + @(selectedVehicle?.DisplayName ?? "-") +
+ } +
+ @(isFelgenCategory ? "Felgen:" : "Reifenstapel:") + @(createdTireStack?.ItemCount ?? TireStack.TiresPerStack) @(isFelgenCategory ? "Felgen" : "Reifen") +
+
+ Saison: + @createdTireStack?.Season +
+ @if (!string.IsNullOrEmpty(createdTireStack?.TireSize)) + { +
+ Größe: + @createdTireStack.TireSize +
+ } + @if (!string.IsNullOrEmpty(createdTireStack?.TireBrand)) + { +
+ Marke: + @createdTireStack.TireBrand @createdTireStack.TireModel +
+ } +
+ Lagerplatz: + + @if (selectedLocation != null) + { + + } + +
+
+ Eingelagert am: + @createdTireStack?.StoredAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm") +
+
+
+ +
+ + + Zur Übersicht + +
+
+ } +
+ +@* Image Zoom Overlay *@ +@if (showImageZoom && zoomImageUrl != null) +{ +
+
+
+ @zoomImageTitle + +
+
+ @zoomImageTitle +
+
+
+} +} + +@code { + private int currentStep = 1; + private bool isInventoryMode = false; + + // Step 1: Customer + private string customerSearch = ""; + private List searchResults = new(); + private Customer? selectedCustomer; + private Customer newCustomer = new(); + private bool showNewCustomerForm; + private bool isSearching; + + // Step 1: Vehicle + private List customerVehicles = new(); + private Vehicle? selectedVehicle; + private Vehicle newVehicle = new(); + private bool showAddVehicleForm; + + // Step 1: Category (Inventory Mode) + private List categories = new(); + private CatalogItem? selectedCategory; + private CatalogItem newCategory = new(); + private bool showNewCategoryForm; + private IBrowserFile? categoryImageFile; + private string? categoryImagePreview; + + // Step 2: Tire Stack Info + private TireStack tireStack = new(); + private bool showDetailsExpanded; + private string? errorMessage; + private bool useCustomItemCount; + + // Step 3: Location + private List allLocations = new(); + private List filteredLocations = new(); + private AreaType? selectedAreaFilter; + private string? selectedRowFilter; + private StorageLocation? selectedLocation; + + // Step 4: Result + private TireStack? createdTireStack; + private bool isProcessing; + + // Image Zoom + private bool showImageZoom; + private string? zoomImageUrl; + private string? zoomImageTitle; + + // Computed helpers + private bool isFelgenCategory => isInventoryMode && selectedCategory?.Type == CatalogItemType.Felgen; + + private bool CanCreateCustomer => + !string.IsNullOrWhiteSpace(newCustomer.LastName) && + !string.IsNullOrWhiteSpace(newVehicle.Make) && + !string.IsNullOrWhiteSpace(newVehicle.TireSize); + + private bool CanCreateVehicle => + !string.IsNullOrWhiteSpace(newVehicle.Make) && + !string.IsNullOrWhiteSpace(newVehicle.TireSize); + + private bool CanCreateCategory => + !string.IsNullOrWhiteSpace(newCategory.Name) && + !string.IsNullOrWhiteSpace(newCategory.TireSize); + + protected override async Task OnInitializedAsync() + { + if (UserSession.IsWorkerOrAdmin) + { + await LoadLocations(); + await LoadCategories(); + } + } + + // ------------------------------------------------------------------ + // Draft auto-save (sessionStorage, per-tab). + // Protects work-in-progress against accidental reload / circuit reset. + // Cleared on successful CompleteCheckin and on ResetForm. + // ------------------------------------------------------------------ + private const string DraftKey = "tire-checkin"; + private bool _draftRestoreAttempted; + private bool _isRestoringDraft; + private CancellationTokenSource? _draftDebounceCts; + private CancellationTokenSource? _searchDebounceCts; + private readonly SemaphoreSlim _searchLock = new(1, 1); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && UserSession.IsWorkerOrAdmin && !_draftRestoreAttempted) + { + _draftRestoreAttempted = true; + await TryRestoreDraftAsync(); + } + } + + private async Task TryRestoreDraftAsync() + { + try + { + var json = await JS.InvokeAsync("appInterop.loadDraft", DraftKey); + if (string.IsNullOrWhiteSpace(json)) return; + + var draft = System.Text.Json.JsonSerializer.Deserialize(json); + if (draft == null) return; + + _isRestoringDraft = true; + try + { + currentStep = draft.CurrentStep < 1 ? 1 : draft.CurrentStep > 3 ? 3 : draft.CurrentStep; + isInventoryMode = draft.IsInventoryMode; + customerSearch = draft.CustomerSearch ?? ""; + useCustomItemCount = draft.UseCustomItemCount; + showDetailsExpanded = draft.ShowDetailsExpanded; + + if (draft.SelectedCustomerId.HasValue) + { + var allCustomers = await CustomerService.GetAllAsync(); + selectedCustomer = allCustomers.FirstOrDefault(c => c.Id == draft.SelectedCustomerId.Value); + if (selectedCustomer != null) + { + customerVehicles = await CustomerService.GetVehiclesAsync(selectedCustomer.Id); + if (draft.SelectedVehicleId.HasValue) + { + selectedVehicle = customerVehicles.FirstOrDefault(v => v.Id == draft.SelectedVehicleId.Value); + } + } + } + + if (!string.IsNullOrEmpty(draft.SelectedCategoryId) && Guid.TryParse(draft.SelectedCategoryId, out var catId)) + { + selectedCategory = categories.FirstOrDefault(c => c.Id == catId); + } + + if (draft.TireStack != null) + { + tireStack = draft.TireStack; + } + + if (draft.SelectedLocationId.HasValue) + { + selectedLocation = allLocations.FirstOrDefault(l => l.Id == draft.SelectedLocationId.Value); + } + + // Mark unsaved so beforeunload warns; the user has active draft state. + await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft()); + } + finally + { + _isRestoringDraft = false; + } + + StateHasChanged(); + } + catch + { + // Corrupt draft — wipe and continue with a fresh form. + try { await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey); } catch { } + } + } + + private bool HasMeaningfulDraft() + { + if (currentStep > 1) return true; + if (selectedCustomer != null) return true; + if (selectedCategory != null) return true; + if (!string.IsNullOrWhiteSpace(customerSearch)) return true; + if (!string.IsNullOrWhiteSpace(tireStack.TireBrand)) return true; + if (!string.IsNullOrWhiteSpace(tireStack.TireModel)) return true; + if (!string.IsNullOrWhiteSpace(tireStack.Notes)) return true; + return false; + } + + /// + /// Queues a debounced draft save (~400 ms). Called by the UI and + /// state-changing methods. Safe to call many times per second. + /// + private async Task SaveDraftAsync() + { + if (_isRestoringDraft) return; + + _draftDebounceCts?.Cancel(); + _draftDebounceCts?.Dispose(); + var cts = new CancellationTokenSource(); + _draftDebounceCts = cts; + + try + { + await Task.Delay(400, cts.Token); + } + catch (TaskCanceledException) + { + return; + } + + if (cts.IsCancellationRequested) return; + + try + { + var draft = new CheckinDraft + { + CurrentStep = currentStep, + IsInventoryMode = isInventoryMode, + CustomerSearch = customerSearch, + SelectedCustomerId = selectedCustomer?.Id, + SelectedVehicleId = selectedVehicle?.Id, + SelectedCategoryId = selectedCategory?.Id.ToString(), + SelectedLocationId = selectedLocation?.Id, + UseCustomItemCount = useCustomItemCount, + ShowDetailsExpanded = showDetailsExpanded, + TireStack = tireStack + }; + + var json = System.Text.Json.JsonSerializer.Serialize(draft); + await JS.InvokeVoidAsync("appInterop.saveDraft", DraftKey, json); + await JS.InvokeVoidAsync("appInterop.setUnsaved", HasMeaningfulDraft()); + } + catch { /* non-fatal */ } + } + + private async Task ClearDraftAsync() + { + try + { + await JS.InvokeVoidAsync("appInterop.clearDraft", DraftKey); + await JS.InvokeVoidAsync("appInterop.setUnsaved", false); + } + catch { /* non-fatal */ } + } + + public async ValueTask DisposeAsync() + { + _draftDebounceCts?.Cancel(); + _draftDebounceCts?.Dispose(); + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchLock.Dispose(); + // Best-effort: clear the "unsaved" flag so beforeunload doesn't + // fire on an unrelated navigation after the component is gone. + try { await JS.InvokeVoidAsync("appInterop.setUnsaved", false); } catch { } + } + + private sealed class CheckinDraft + { + public int CurrentStep { get; set; } = 1; + public bool IsInventoryMode { get; set; } + public string? CustomerSearch { get; set; } + public Guid? SelectedCustomerId { get; set; } + public Guid? SelectedVehicleId { get; set; } + public string? SelectedCategoryId { get; set; } + public Guid? SelectedLocationId { get; set; } + public bool UseCustomItemCount { get; set; } + public bool ShowDetailsExpanded { get; set; } + public TireStack? TireStack { get; set; } + } + + private async Task LoadLocations() + { + allLocations = await StorageLocationService.GetAvailableAsync(); + ApplyLocationFilter(); + } + + private async Task LoadCategories() + { + categories = await CatalogService.GetAllAsync(); + } + + private void FilterLocationsByArea(AreaType? area) + { + selectedAreaFilter = area; + ApplyLocationFilter(); + } + + private void ApplyLocationFilter() + { + var query = allLocations.Where(l => !l.IsOccupied && l.IsAvailable); + + if (selectedAreaFilter.HasValue) + { + query = query.Where(l => l.Area == selectedAreaFilter.Value); + } + + if (!string.IsNullOrEmpty(selectedRowFilter) && int.TryParse(selectedRowFilter, out var row)) + { + query = query.Where(l => l.Row == row); + } + + filteredLocations = query + .OrderBy(l => l.Area) + .ThenBy(l => l.Row) + .ThenBy(l => l.SlotNumber) + .ToList(); + } + + private void SetMode(bool inventoryMode) + { + isInventoryMode = inventoryMode; + selectedCustomer = null; + selectedCategory = null; + selectedVehicle = null; + customerVehicles.Clear(); + customerSearch = ""; + searchResults.Clear(); + showNewCustomerForm = false; + showNewCategoryForm = false; + showAddVehicleForm = false; + newVehicle = new Vehicle(); + useCustomItemCount = false; + categoryImageFile = null; + categoryImagePreview = null; + _ = SaveDraftAsync(); + } + + private async Task SearchCustomers() + { + if (string.IsNullOrWhiteSpace(customerSearch) || customerSearch.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; + + isSearching = true; + StateHasChanged(); + + searchResults = await CustomerService.SearchAsync(customerSearch); + } + finally + { + isSearching = false; + _searchLock.Release(); + } + } + + private async Task SelectCustomer(Customer customer) + { + selectedCustomer = customer; + showNewCustomerForm = false; + showAddVehicleForm = false; + newVehicle = new Vehicle(); + + // Load customer's vehicles + customerVehicles = await CustomerService.GetVehiclesAsync(customer.Id); + + // Auto-select primary vehicle or single vehicle + if (customerVehicles.Count == 1) + { + selectedVehicle = customerVehicles[0]; + } + else if (customerVehicles.Count > 1) + { + selectedVehicle = customerVehicles.FirstOrDefault(v => v.IsPrimary); + } + else + { + selectedVehicle = null; + } + + _ = SaveDraftAsync(); + } + + private void SelectVehicle(Vehicle vehicle) + { + selectedVehicle = vehicle; + showAddVehicleForm = false; + _ = SaveDraftAsync(); + } + + private void ShowNewVehicleForm() + { + showAddVehicleForm = true; + newVehicle = new Vehicle(); + } + + private void CancelNewVehicle() + { + showAddVehicleForm = false; + newVehicle = new Vehicle(); + } + + private async Task AddNewVehicle() + { + if (!CanCreateVehicle || selectedCustomer == null) return; + + try + { + var created = await CustomerService.AddVehicleAsync(selectedCustomer.Id, newVehicle); + customerVehicles.Add(created); + selectedVehicle = created; + showAddVehicleForm = false; + newVehicle = new Vehicle(); + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Hinzufügen: {ex.Message}"; + } + } + + private void SelectCategory(CatalogItem category) + { + selectedCategory = category; + showNewCategoryForm = false; + tireStack.TireSize = category.TireSize; + + if (category.Type == CatalogItemType.Reifen) + { + tireStack.Season = category.Season; + } + else + { + // Felgen: no season, no rims toggle (they ARE rims) + tireStack.WithRims = false; + } + + _ = SaveDraftAsync(); + } + + private void ShowNewCustomerForm() + { + showNewCustomerForm = true; + newCustomer = new Customer(); + newVehicle = new Vehicle(); + } + + private void CancelNewCustomer() + { + showNewCustomerForm = false; + newCustomer = new Customer(); + newVehicle = new Vehicle(); + } + + private async Task CreateNewCustomer() + { + if (!CanCreateCustomer) return; + + try + { + // Create customer with vehicle + selectedCustomer = await CustomerService.CreateWithVehicleAsync(newCustomer, newVehicle); + + // Reload vehicles and select the new one + customerVehicles = await CustomerService.GetVehiclesAsync(selectedCustomer.Id); + selectedVehicle = customerVehicles.FirstOrDefault(); + + showNewCustomerForm = false; + searchResults.Clear(); + customerSearch = ""; + newCustomer = new Customer(); + newVehicle = new Vehicle(); + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Anlegen: {ex.Message}"; + } + } + + private void ShowNewCategoryForm() + { + showNewCategoryForm = true; + newCategory = new CatalogItem(); + categoryImageFile = null; + categoryImagePreview = null; + } + + private void CancelNewCategory() + { + showNewCategoryForm = false; + newCategory = new CatalogItem(); + categoryImageFile = null; + categoryImagePreview = null; + } + + private async Task CreateNewCategory() + { + if (!CanCreateCategory) return; + + try + { + // Upload image if provided (Felgen category) + if (categoryImageFile != null && newCategory.Type == CatalogItemType.Felgen) + { + using var stream = categoryImageFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var relativePath = await FileStorageService.SaveFileAsync(ms.ToArray(), categoryImageFile.Name, "catalog-images"); + newCategory.PictureFileName = relativePath; + } + + var created = await CatalogService.CreateAsync(newCategory); + categories.Add(created); + selectedCategory = created; + showNewCategoryForm = false; + categoryImageFile = null; + categoryImagePreview = null; + + if (created.Type == CatalogItemType.Reifen) + { + tireStack.Season = created.Season; + } + else + { + tireStack.WithRims = false; + } + tireStack.TireSize = created.TireSize; + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Anlegen: {ex.Message}"; + } + } + + private void GoToStep(int step) + { + currentStep = step; + _ = SaveDraftAsync(); + } + + private void GoToStep2() + { + if (isInventoryMode && selectedCategory != null) + { + currentStep = 2; + } + else if (!isInventoryMode && selectedCustomer != null) + { + currentStep = 2; + } + _ = SaveDraftAsync(); + } + + private void GoBackToStep1() + { + currentStep = 1; + _ = SaveDraftAsync(); + } + + private void GoBackToStep2() + { + currentStep = 2; + _ = SaveDraftAsync(); + } + + private async Task GoToStep3() + { + await LoadLocations(); + currentStep = 3; + _ = SaveDraftAsync(); + } + + private void ToggleDetails() + { + showDetailsExpanded = !showDetailsExpanded; + _ = SaveDraftAsync(); + } + + private void SelectLocation(StorageLocation location) + { + if (!location.IsOccupied && location.IsAvailable) + { + selectedLocation = location; + _ = SaveDraftAsync(); + } + } + + private void ClearSelectedLocation() + { + selectedLocation = null; + } + + private async Task CompleteCheckin() + { + if (selectedLocation == null) return; + if (!isInventoryMode && selectedCustomer == null) return; + if (isInventoryMode && selectedCategory == null) return; + + isProcessing = true; + errorMessage = null; + StateHasChanged(); + + try + { + if (isInventoryMode) + { + tireStack.CustomerId = null; + tireStack.VehicleId = null; + tireStack.CatalogItemId = selectedCategory!.Id; + if (!useCustomItemCount) + { + tireStack.ItemCount = TireStack.TiresPerStack; + } + } + else + { + tireStack.CustomerId = selectedCustomer!.Id; + tireStack.VehicleId = selectedVehicle?.Id; + tireStack.ItemCount = TireStack.TiresPerStack; + } + + var created = await TireStackService.CreateAndStoreAsync( + tireStack, + selectedLocation.Id, + UserSession.CurrentUser?.Id); + + createdTireStack = created; + + currentStep = 4; + await ClearDraftAsync(); + } + catch (Exception ex) + { + errorMessage = $"Fehler beim Einlagern: {ex.Message}"; + selectedLocation = null; + await LoadLocations(); + } + finally + { + isProcessing = false; + } + } + + private void ResetForm() + { + currentStep = 1; + customerSearch = ""; + searchResults.Clear(); + selectedCustomer = null; + selectedCategory = null; + selectedVehicle = null; + customerVehicles.Clear(); + newCustomer = new Customer(); + newVehicle = new Vehicle(); + newCategory = new CatalogItem(); + showNewCustomerForm = false; + showNewCategoryForm = false; + showAddVehicleForm = false; + tireStack = new TireStack(); + showDetailsExpanded = false; + selectedLocation = null; + selectedAreaFilter = null; + selectedRowFilter = null; + createdTireStack = null; + errorMessage = null; + useCustomItemCount = false; + categoryImageFile = null; + categoryImagePreview = null; + showImageZoom = false; + zoomImageUrl = null; + zoomImageTitle = null; + _ = ClearDraftAsync(); + } + + // Category type toggle + private void SetNewCategoryType(CatalogItemType type) + { + newCategory.Type = type; + if (type == CatalogItemType.Felgen) + { + categoryImageFile = null; + categoryImagePreview = null; + } + } + + private void SetRimType(bool isAlu) + { + tireStack.RimSize = isAlu ? "Alu" : "Stahl"; + } + + private void OnTireSizeChanged(string? value) + { + tireStack.TireSize = value; + + // Auto-derive rim size from the R value of the tire size (e.g. "225/45 R17" -> "17 Zoll") + var match = System.Text.RegularExpressions.Regex.Match(value ?? string.Empty, @"R(\d{2})$"); + if (match.Success) + { + tireStack.RimSize = $"{match.Groups[1].Value} Zoll"; + } + else if (!string.IsNullOrEmpty(tireStack.RimSize) && tireStack.RimSize.EndsWith(" Zoll")) + { + // Clear only auto-derived values when the R part is removed again + tireStack.RimSize = null; + } + } + + // File upload for Felgen category image + private async Task OnCategoryImageSelected(InputFileChangeEventArgs e) + { + var file = e.File; + if (file == null) return; + + var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" }; + if (!allowedTypes.Contains(file.ContentType.ToLowerInvariant())) + { + errorMessage = "Ungültiger Dateityp. Erlaubt: JPG, PNG, GIF, WEBP"; + return; + } + + if (file.Size > 10 * 1024 * 1024) + { + errorMessage = "Datei ist zu groß (max. 10 MB)"; + return; + } + + categoryImageFile = file; + errorMessage = null; + + // Generate preview + try + { + var resized = await file.RequestImageFileAsync(file.ContentType, 300, 300); + using var stream = resized.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + categoryImagePreview = $"data:{file.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}"; + } + catch + { + categoryImagePreview = null; + } + } + + private void RemoveCategoryImage() + { + categoryImageFile = null; + categoryImagePreview = null; + } + + // Item count + private void ToggleCustomItemCount() + { + useCustomItemCount = !useCustomItemCount; + if (!useCustomItemCount) + { + tireStack.ItemCount = TireStack.TiresPerStack; + } + } + + private void AdjustItemCount(int delta) + { + var newCount = tireStack.ItemCount + delta; + if (newCount >= 1 && newCount <= 99) + { + tireStack.ItemCount = newCount; + } + } + + // Image zoom + private string GetCategoryImageUrl(CatalogItem category) + { + if (string.IsNullOrEmpty(category.PictureFileName)) return ""; + return $"/api/catalog-image/{Uri.EscapeDataString(category.PictureFileName)}"; + } + + private void ShowImageZoom(CatalogItem category) + { + if (string.IsNullOrEmpty(category.PictureFileName)) return; + zoomImageUrl = GetCategoryImageUrl(category); + zoomImageTitle = category.Name; + showImageZoom = true; + } + + private void CloseImageZoom() + { + showImageZoom = false; + zoomImageUrl = null; + zoomImageTitle = null; + } +} diff --git a/Lagerbestand/Components/Pages/TireCheckin.razor.css b/Lagerbestand/Components/Pages/TireCheckin.razor.css new file mode 100644 index 0000000..faf81a4 --- /dev/null +++ b/Lagerbestand/Components/Pages/TireCheckin.razor.css @@ -0,0 +1,2239 @@ +/* ======================================== + Tire Check-in Page Styles + Mobile-first, clean design + ======================================== */ + +.checkin-page { + min-height: 100vh; + min-height: 100dvh; + background: var(--color-bg-gradient); + padding: 0.75rem; + padding-bottom: calc(1rem + var(--safe-area-bottom)); +} + +@media (min-width: 480px) { + .checkin-page { + padding: 1rem; + } +} + +@media (min-width: 768px) { + .checkin-page { + padding: 2rem 3rem; + } +} + +@media (min-width: 1024px) { + .checkin-page { + padding: 3rem 4rem; + } +} + +/* ======================================== + Progress Steps - Responsive + ======================================== */ +.progress-steps { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 0 1.25rem; + gap: 0; + max-width: 600px; + margin: 0 auto; +} + +@media (min-width: 480px) { + .progress-steps { + padding: 1rem 0 1.5rem; + } +} + +@media (min-width: 768px) { + .progress-steps { + max-width: 700px; + } +} + +.step { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + opacity: 0.4; + transition: opacity 0.3s ease; +} + +@media (min-width: 480px) { + .step { + gap: 6px; + } +} + +.step.active { + opacity: 1; +} + +.step.completed { + opacity: 0.7; +} + +.step-number { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Rajdhani', sans-serif; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + font-size: 0.8rem; + transition: all 0.3s ease; +} + +@media (min-width: 480px) { + .step-number { + width: 32px; + height: 32px; + font-size: 0.9rem; + } +} + +@media (min-width: 768px) { + .step-number { + width: 40px; + height: 40px; + font-size: 1rem; + } +} + +.step.active .step-number { + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); + border-color: #ef4444; + color: #fff; +} + +.step.completed .step-number { + background: rgba(34, 197, 94, 0.2); + border-color: rgba(34, 197, 94, 0.5); + color: #86efac; +} + +.step.completed .step-number::after { + content: '✓'; +} + +.step-label { + font-family: 'Rajdhani', sans-serif; + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@media (min-width: 480px) { + .step-label { + font-size: 0.75rem; + } +} + +@media (min-width: 768px) { + .step-label { + font-size: 0.85rem; + } +} + +.step.active .step-label { + color: rgba(255, 255, 255, 0.9); +} + +.step-line { + width: 24px; + height: 2px; + background: rgba(255, 255, 255, 0.1); + margin: 0 4px; + margin-bottom: 18px; + transition: background 0.3s ease; +} + +@media (min-width: 480px) { + .step-line { + width: 40px; + margin: 0 8px; + margin-bottom: 20px; + } +} + +@media (min-width: 768px) { + .step-line { + width: 80px; + } +} + +.step-line.completed { + background: rgba(34, 197, 94, 0.5); +} + +/* ======================================== + Check-in Card - Responsive + ======================================== */ +.checkin-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: 16px; + padding: 1.25rem; + max-width: 600px; + margin: 0 auto; +} + +@media (min-width: 480px) { + .checkin-card { + padding: 1.5rem; + border-radius: 20px; + } +} + +@media (min-width: 768px) { + .checkin-card { + padding: 2.5rem; + max-width: 700px; + border-radius: 24px; + } +} + +@media (min-width: 1024px) { + .checkin-card { + max-width: 800px; + padding: 3rem; + } +} + +.checkin-card h2 { + font-family: 'Rajdhani', sans-serif; + font-size: clamp(1.25rem, 4vw, 1.75rem); + font-weight: 600; + color: #ffffff; + margin: 0 0 1.25rem 0; + text-align: center; +} + +@media (min-width: 480px) { + .checkin-card h2 { + margin: 0 0 1.5rem 0; + } +} + +/* ======================================== + Mode Toggle - Responsive + ======================================== */ +.mode-toggle { + display: flex; + gap: 6px; + margin-bottom: 1.25rem; +} + +@media (min-width: 480px) { + .mode-toggle { + gap: 8px; + margin-bottom: 1.5rem; + } +} + +.mode-btn { + flex: 1; + padding: 10px 8px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + line-height: 1.4; + min-height: 44px; +} + +@media (min-width: 480px) { + .mode-btn { + padding: 12px; + border-radius: 10px; + font-size: 0.9rem; + } +} + +.mode-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +.mode-btn.active { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); + color: #ffffff; +} + +/* ======================================== + Last Scan Display - NEW + ======================================== */ +.last-scan-display { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%); + border: 1px solid rgba(34, 197, 94, 0.4); + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 10px; + animation: scanPulse 0.5s ease-out; +} + +.last-scan-display.location { + background: linear-gradient(135deg, rgba(96, 165, 250, 0.2) 0%, rgba(96, 165, 250, 0.1) 100%); + border-color: rgba(96, 165, 250, 0.4); +} + +@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: #86efac; + flex: 1; + word-break: break-all; +} + +.last-scan-display.location .scan-code { + color: #93c5fd; +} + +/* ======================================== + Scan Overlay + ======================================== */ +.camera-wrapper { + position: relative; + background: #000; + border-radius: 16px; + overflow: hidden; + aspect-ratio: 1; + max-height: 280px; + margin-bottom: 1rem; +} + +.scan-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(0deg, rgba(34, 197, 94, 0.9) 0%, rgba(34, 197, 94, 0.7) 100%); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: translateY(100%); + transition: all 0.25s ease; +} + +.scan-overlay.visible { + opacity: 1; + transform: translateY(0); + animation: scanFlash 0.3s ease-out; +} + +@keyframes scanFlash { + 0% { background: rgba(34, 197, 94, 1); } + 100% { background: rgba(34, 197, 94, 0.9); } +} + +.scan-overlay .scan-check { + font-size: 1.5rem; + color: #fff; + font-weight: bold; +} + +/* ======================================== + Search Box - Responsive + ======================================== */ +.search-box { + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .search-box { + margin-bottom: 1rem; + } +} + +.search-box .form-control { + width: 100%; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + color: #ffffff; + font-family: 'Rajdhani', sans-serif; + font-size: 16px; /* Prevents iOS zoom */ + box-sizing: border-box; +} + +@media (min-width: 480px) { + .search-box .form-control { + padding: 14px 16px; + border-radius: 12px; + } +} + +.search-box .form-control:focus { + outline: none; + border-color: rgba(220, 38, 38, 0.5); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); +} + +.search-box .form-control::placeholder { + color: rgba(255, 255, 255, 0.35); +} + +/* ======================================== + Loading Indicator - Responsive + ======================================== */ +.loading-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 1.5rem; + color: rgba(255, 255, 255, 0.6); + font-family: 'Rajdhani', sans-serif; +} + +@media (min-width: 480px) { + .loading-indicator { + padding: 2rem; + } +} + +.spinner { + width: 18px; + height: 18px; + border: 2px 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: 20px; + height: 20px; + } +} + +/* ======================================== + Customer List - Responsive + ======================================== */ +.customer-list { + max-height: 250px; + overflow-y: auto; + margin-bottom: 0.75rem; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 480px) { + .customer-list { + max-height: 300px; + margin-bottom: 1rem; + } +} + +@media (min-width: 768px) { + .customer-list { + max-height: 350px; + } +} + +@media (min-width: 1024px) { + .customer-list { + max-height: 400px; + } +} + +.customer-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.2); + border: 0.5px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + margin-bottom: 6px; + cursor: pointer; + transition: all 0.2s ease; + gap: 10px; +} + +@media (min-width: 480px) { + .customer-item { + padding: 12px 16px; + border-radius: 12px; + margin-bottom: 8px; + gap: 12px; + } +} + +.customer-item:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); +} + +.customer-item.selected { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); +} + +.customer-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.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-details { + font-family: 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.4); + font-size: 0.75rem; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +@media (min-width: 480px) { + .customer-details { + font-size: 0.8rem; + gap: 10px; + } +} + +.vehicle-make { + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-weight: 500; +} + +@media (min-width: 480px) { + .vehicle-make { + padding: 2px 8px; + } +} + +.customer-badge { + background: rgba(255, 255, 255, 0.1); + padding: 4px 8px; + border-radius: 6px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; + flex-shrink: 0; +} + +@media (min-width: 480px) { + .customer-badge { + padding: 4px 10px; + border-radius: 8px; + font-size: 0.8rem; + } +} + +/* ======================================== + No Results - Responsive + ======================================== */ +.no-results { + text-align: center; + padding: 1.5rem; + color: rgba(255, 255, 255, 0.5); + font-family: 'Rajdhani', sans-serif; +} + +@media (min-width: 480px) { + .no-results { + padding: 2rem; + } +} + +.no-results p { + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .no-results p { + margin-bottom: 1rem; + } +} + +/* ======================================== + New Customer Form - Responsive + ======================================== */ +.new-customer-form, +.new-category-form { + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + padding: 1rem; + margin-top: 0.75rem; +} + +@media (min-width: 480px) { + .new-customer-form, + .new-category-form { + border-radius: 16px; + padding: 1.25rem; + margin-top: 1rem; + } +} + +.new-customer-form h3, +.new-category-form h3 { + font-family: 'Rajdhani', sans-serif; + font-size: 1rem; + font-weight: 600; + color: #ffffff; + margin: 0 0 0.75rem 0; +} + +@media (min-width: 480px) { + .new-customer-form h3, + .new-category-form h3 { + font-size: 1.1rem; + margin: 0 0 1rem 0; + } +} + +.form-row { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-bottom: 10px; +} + +@media (min-width: 480px) { + .form-row { + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +@media (min-width: 480px) { + .form-group { + gap: 6px; + } +} + +.form-group label { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); + font-weight: 500; +} + +@media (min-width: 480px) { + .form-group label { + font-size: 0.85rem; + } +} + +/* Form controls: inherited from app.css (section 4) — page-specific overrides */ +.form-control { + font-size: 16px; /* Prevents iOS zoom */ +} + +textarea.form-control { + resize: vertical; + min-height: 60px; +} + +select.form-control { + cursor: pointer; +} + +.form-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 0.75rem; +} + +@media (min-width: 480px) { + .form-actions { + flex-direction: row; + gap: 10px; + justify-content: flex-end; + margin-top: 1rem; + } +} + +@media (max-width: 479.98px) { + .form-actions .btn { + width: 100%; + justify-content: center; + } +} + +/* ======================================== + Tire Form - Responsive + ======================================== */ +.tire-form { + margin-top: 1.25rem; +} + +@media (min-width: 480px) { + .tire-form { + margin-top: 1.5rem; + } +} + +.tire-form .form-group { + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .tire-form .form-group { + margin-bottom: 1rem; + } +} + +.tire-form .form-group > label { + margin-bottom: 6px; + font-size: 0.85rem; +} + +@media (min-width: 480px) { + .tire-form .form-group > label { + margin-bottom: 8px; + font-size: 0.9rem; + } +} + +/* Buttons: inherited from app.css (section 5) */ +.btn-link { + background: none; + border: none; + color: var(--color-primary); + cursor: pointer; + font-family: var(--font-family-base); + font-size: var(--text-sm); + padding: var(--space-1) var(--space-2); + min-height: auto; +} + +@media (min-width: 480px) { + .btn-link { + font-size: var(--text-base); + } +} + +.btn-link:hover { + text-decoration: underline; +} + +/* ======================================== + Step Actions - Responsive + ======================================== */ +.step-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 0.5px solid rgba(255, 255, 255, 0.08); +} + +@media (min-width: 480px) { + .step-actions { + flex-direction: row; + gap: 12px; + justify-content: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + } +} + +@media (max-width: 479.98px) { + .step-actions .btn { + width: 100%; + } +} + +/* ======================================== + Selected Customer/Info Bar - Responsive + ======================================== */ +.selected-customer-bar, +.selected-info-bar { + background: rgba(220, 38, 38, 0.1); + border: 0.5px solid rgba(220, 38, 38, 0.25); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + font-family: 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.7); + font-size: 0.85rem; +} + +@media (min-width: 480px) { + .selected-customer-bar, + .selected-info-bar { + border-radius: 10px; + padding: 10px 14px; + margin-bottom: 1.5rem; + font-size: 0.9rem; + } +} + +.selected-info-bar { + flex-direction: column; + align-items: flex-start; +} + +/* ======================================== + Season Selector - Responsive + ======================================== */ +.season-selector { + display: flex; + gap: 6px; +} + +@media (min-width: 480px) { + .season-selector { + gap: 8px; + } +} + +.season-btn { + flex: 1; + padding: 10px 8px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; + line-height: 1.4; + min-height: 44px; +} + +@media (min-width: 480px) { + .season-btn { + padding: 12px; + border-radius: 10px; + font-size: 0.9rem; + } +} + +.season-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +.season-btn.active.summer { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.4); + color: #fbbf24; +} + +.season-btn.active.winter { + background: rgba(96, 165, 250, 0.15); + border-color: rgba(96, 165, 250, 0.4); + color: #60a5fa; +} + +.season-btn.active.allseason { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); + color: #22c55e; +} + +/* ======================================== + Toggle Group - Responsive + ======================================== */ +.toggle-group { + display: flex; + gap: 6px; +} + +@media (min-width: 480px) { + .toggle-group { + gap: 8px; + } +} + +.toggle-btn { + flex: 1; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: rgba(255, 255, 255, 0.6); + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + min-height: 40px; +} + +@media (min-width: 480px) { + .toggle-btn { + padding: 10px 16px; + border-radius: 8px; + font-size: 0.9rem; + min-height: 44px; + } +} + +.toggle-btn.active { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); + color: #ffffff; +} + +/* ======================================== + Category List - Responsive + ======================================== */ +.category-list { + max-height: 250px; + overflow-y: auto; + margin-bottom: 0.75rem; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 480px) { + .category-list { + max-height: 300px; + margin-bottom: 1rem; + } +} + +.category-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.2); + border: 0.5px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + margin-bottom: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +@media (min-width: 480px) { + .category-item { + gap: 12px; + padding: 14px 16px; + border-radius: 12px; + margin-bottom: 8px; + } +} + +.category-item:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); +} + +.category-item.selected { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); +} + +.category-icon { + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 1.25rem; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + line-height: 1; + flex-shrink: 0; +} + +@media (min-width: 480px) { + .category-icon { + font-size: 1.5rem; + width: 40px; + height: 40px; + border-radius: 10px; + } +} + +.category-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.category-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) { + .category-name { + font-size: 1rem; + } +} + +.category-details { + font-family: 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.4); + font-size: 0.8rem; +} + +@media (min-width: 480px) { + .category-details { + font-size: 0.85rem; + } +} + +/* ======================================== + Stack Info Banner - Responsive + ======================================== */ +.stack-info-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(96, 165, 250, 0.1); + border: 0.5px solid rgba(96, 165, 250, 0.25); + border-radius: 10px; + padding: 10px 16px; + margin-bottom: 1.25rem; +} + +@media (min-width: 480px) { + .stack-info-banner { + gap: 10px; + border-radius: 12px; + padding: 12px 18px; + margin-bottom: 1.5rem; + } +} + +.stack-icon { + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 1.25rem; + line-height: 1; +} + +@media (min-width: 480px) { + .stack-icon { + font-size: 1.5rem; + } +} + +.stack-text { + font-family: 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.7); + font-size: 0.85rem; +} + +@media (min-width: 480px) { + .stack-text { + font-size: 0.9rem; + } +} + +/* ======================================== + Details Accordion - Responsive + ======================================== */ +.details-accordion { + margin-top: 1.25rem; + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + overflow: hidden; +} + +@media (min-width: 480px) { + .details-accordion { + margin-top: 1.5rem; + border-radius: 12px; + } +} + +.accordion-header { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.2); + border: none; + color: rgba(255, 255, 255, 0.7); + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-height: 48px; +} + +@media (min-width: 480px) { + .accordion-header { + padding: 14px 16px; + font-size: 0.95rem; + } +} + +.accordion-header:hover { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.9); +} + +.accordion-icon { + font-size: 1.2rem; + font-weight: 300; + color: rgba(255, 255, 255, 0.5); +} + +.accordion-content { + padding: 14px; + background: rgba(0, 0, 0, 0.15); + border-top: 0.5px solid rgba(255, 255, 255, 0.08); +} + +@media (min-width: 480px) { + .accordion-content { + padding: 16px; + } +} + +.accordion-content .form-row:last-child, +.accordion-content .form-group:last-child { + margin-bottom: 0; +} + +/* ======================================== + Area Filter - Responsive + ======================================== */ +.area-filter { + display: flex; + gap: 6px; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +@media (min-width: 480px) { + .area-filter { + gap: 8px; + margin-bottom: 1rem; + } +} + +.filter-btn { + padding: 10px 12px; + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + min-height: 44px; +} + +@media (min-width: 480px) { + .filter-btn { + padding: 10px 14px; + border-radius: 10px; + font-size: 0.9rem; + } +} + +.filter-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +.filter-btn.active { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); + color: #ffffff; +} + +.filter-btn.active.lager { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.4); + color: #fbbf24; +} + +.filter-btn.active.werkstatt { + background: rgba(96, 165, 250, 0.15); + border-color: rgba(96, 165, 250, 0.4); + color: #60a5fa; +} + +/* ======================================== + Row Filter - Responsive + ======================================== */ +.row-filter { + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .row-filter { + margin-bottom: 1rem; + } +} + +/* ======================================== + Area Section - Responsive + ======================================== */ +.area-section { + margin-bottom: 1.25rem; +} + +@media (min-width: 480px) { + .area-section { + margin-bottom: 1.5rem; + } +} + +.area-title { + font-family: 'Rajdhani', sans-serif; + font-size: 1rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + margin: 0 0 0.5rem 0; +} + +@media (min-width: 480px) { + .area-title { + font-size: 1.1rem; + margin: 0 0 0.75rem 0; + } +} + +.row-section { + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .row-section { + margin-bottom: 1rem; + } +} + +.row-label { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + display: block; + margin-bottom: 0.5rem; +} + +@media (min-width: 480px) { + .row-label { + font-size: 0.85rem; + } +} + +/* ======================================== + Location Grid - Responsive + ======================================== */ +.location-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +@media (min-width: 400px) { + .location-grid { + grid-template-columns: repeat(4, 1fr); + gap: 8px; + } +} + +@media (min-width: 768px) { + .location-grid { + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + } +} + +@media (min-width: 1024px) { + .location-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + } +} + +.location-tile { + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + min-height: 60px; +} + +@media (min-width: 480px) { + .location-tile { + gap: 4px; + border-radius: 10px; + min-height: 70px; + } +} + +.location-tile:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); +} + +.location-tile.selected { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); +} + +.location-tile.occupied { + opacity: 0.4; + cursor: not-allowed; +} + +.tile-code { + font-family: 'Rajdhani', sans-serif; + font-weight: 600; + color: #ffffff; + font-size: 0.8rem; +} + +@media (min-width: 480px) { + .tile-code { + font-size: 0.9rem; + } +} + +.tile-slot { + font-family: 'Rajdhani', sans-serif; + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.5); +} + +@media (min-width: 480px) { + .tile-slot { + font-size: 0.7rem; + } +} + +/* ======================================== + Selected Location - Responsive + ======================================== */ +.selected-location { + display: flex; + align-items: center; + gap: 10px; + background: rgba(34, 197, 94, 0.1); + border: 0.5px solid rgba(34, 197, 94, 0.3); + border-radius: 10px; + padding: 10px 14px; + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .selected-location { + gap: 12px; + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 1rem; + } +} + +.location-icon { + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 1.25rem; + line-height: 1; +} + +@media (min-width: 480px) { + .location-icon { + font-size: 1.5rem; + } +} + +.location-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.location-code { + font-family: 'Rajdhani', sans-serif; + font-weight: 600; + color: #ffffff; + font-size: 1rem; +} + +@media (min-width: 480px) { + .location-code { + font-size: 1.1rem; + } +} + +.location-path { + font-family: 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.5); + font-size: 0.8rem; +} + +@media (min-width: 480px) { + .location-path { + font-size: 0.85rem; + } +} + +.btn-clear { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 50%; + width: 28px; + height: 28px; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + min-width: 28px; +} + +.btn-clear:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Alerts: inherited from app.css (section 7) */ +.alert { + margin-bottom: var(--space-3); +} + +@media (min-width: 480px) { + .alert { + margin-bottom: var(--space-4); + } +} + +/* ======================================== + Success Card - Responsive + ======================================== */ +.success-card { + text-align: center; +} + +.success-icon { + width: 70px; + height: 70px; + 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.25rem; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 1.75rem; + color: #22c55e; + line-height: 1; +} + +@media (min-width: 480px) { + .success-icon { + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + font-size: 2rem; + } +} + +/* ======================================== + Ticket Preview - Responsive + ======================================== */ +.ticket-preview { + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + overflow: hidden; + margin-bottom: 1.25rem; + text-align: left; +} + +@media (min-width: 480px) { + .ticket-preview { + border-radius: 16px; + margin-bottom: 1.5rem; + } +} + +.ticket-header { + background: rgba(255, 255, 255, 0.05); + padding: 10px 14px; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +@media (min-width: 480px) { + .ticket-header { + padding: 12px 16px; + gap: 12px; + } +} + +.ticket-number { + font-family: 'Orbitron', monospace; + font-size: 1rem; + font-weight: 600; + color: #ef4444; +} + +@media (min-width: 480px) { + .ticket-number { + font-size: 1.2rem; + } +} + +.ticket-header .inventory-badge { + padding: 4px 8px; + background: rgba(96, 165, 250, 0.15); + border: 1px solid rgba(96, 165, 250, 0.3); + border-radius: 6px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.7rem; + color: #93c5fd; + font-weight: 600; +} + +@media (min-width: 480px) { + .ticket-header .inventory-badge { + padding: 4px 10px; + font-size: 0.75rem; + } +} + +.ticket-details { + padding: 14px; +} + +@media (min-width: 480px) { + .ticket-details { + padding: 16px; + } +} + +.detail-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.05); + font-family: 'Rajdhani', sans-serif; + gap: 10px; +} + +@media (min-width: 480px) { + .detail-row { + padding: 8px 0; + } +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-row .label { + color: rgba(255, 255, 255, 0.5); + font-size: 0.85rem; + flex-shrink: 0; +} + +@media (min-width: 480px) { + .detail-row .label { + font-size: 0.9rem; + } +} + +.detail-row .value { + color: #ffffff; + font-weight: 500; + text-align: right; + word-break: break-word; +} + +.detail-row .value.qr-codes { + font-family: 'Orbitron', monospace; + font-size: 0.75rem; + color: #86efac; + word-break: break-all; + max-width: 60%; +} + +@media (min-width: 480px) { + .detail-row .value.qr-codes { + font-size: 0.8rem; + } +} + +/* Reduced motion: inherited from app.css (section 17) */ + +/* ======================================== + Vehicle Selection Section + ======================================== */ +.vehicle-selection-section { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 0.5px solid rgba(255, 255, 255, 0.1); +} + +.vehicle-selection-section h3 { + font-family: 'Rajdhani', sans-serif; + font-size: 1rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + margin: 0 0 0.75rem 0; +} + +.no-vehicles-info { + padding: 1rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + text-align: center; +} + +.no-vehicles-info p { + color: rgba(255, 255, 255, 0.5); + margin: 0; + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; +} + +.vehicle-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vehicle-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.03); + border: 0.5px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + min-height: 48px; +} + +.vehicle-item:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); +} + +.vehicle-item.selected { + background: rgba(220, 38, 38, 0.1); + border-color: rgba(220, 38, 38, 0.4); +} + +.vehicle-info { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.vehicle-name { + font-family: 'Rajdhani', sans-serif; + font-weight: 600; + color: #ffffff; + font-size: 0.95rem; +} + +.vehicle-meta { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); +} + +.primary-badge { + font-family: 'Rajdhani', sans-serif; + font-size: 0.7rem; + font-weight: 600; + background: rgba(96, 165, 250, 0.15); + border: 1px solid rgba(96, 165, 250, 0.3); + color: #93c5fd; + padding: 2px 8px; + border-radius: 4px; +} + +.vehicle-badge { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + background: rgba(255, 255, 255, 0.08); + padding: 4px 10px; + border-radius: 6px; + color: rgba(255, 255, 255, 0.7); + margin-left: 8px; +} + +/* New Vehicle Form */ +.new-vehicle-form { + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 1rem; +} + +.new-vehicle-form h4 { + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + margin: 0 0 0.75rem 0; +} + +.form-section-title { + font-family: 'Rajdhani', sans-serif; + font-size: 0.95rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.7); + margin: 1rem 0 0.5rem 0; + padding-top: 0.75rem; + border-top: 0.5px solid rgba(255, 255, 255, 0.1); +} + +.mt-3 { + margin-top: 0.75rem; +} + +.btn-sm { + padding: 8px 14px; + font-size: 0.85rem; +} + +.btn-sm.mt-3 { + margin-top: 0.75rem; +} + +@media (min-width: 480px) { + .btn-sm.mt-3 { + margin-top: 1rem; + } +} + +/* ======================================== + Category Type Toggle (Reifen/Felgen) + ======================================== */ +.category-type-toggle { + display: flex; + gap: 6px; +} + +@media (min-width: 480px) { + .category-type-toggle { + gap: 8px; + } +} + +.type-btn { + flex: 1; + padding: 10px 8px; + background: rgba(0, 0, 0, 0.3); + border: 0.5px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-family: "Apple Color Emoji", "Segoe UI Emoji", 'Rajdhani', sans-serif; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + line-height: 1.4; + min-height: 44px; +} + +@media (min-width: 480px) { + .type-btn { + padding: 12px; + border-radius: 10px; + font-size: 0.9rem; + } +} + +.type-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +.type-btn.active.reifen { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); + color: #ffffff; +} + +.type-btn.active.felgen { + background: rgba(168, 85, 247, 0.15); + border-color: rgba(168, 85, 247, 0.4); + color: #c084fc; +} + +/* ======================================== + Category Type Badge + ======================================== */ +.category-type-badge { + display: inline-block; + font-family: 'Rajdhani', sans-serif; + font-size: 0.65rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + width: fit-content; +} + +.category-type-badge.reifen { + background: rgba(220, 38, 38, 0.12); + color: rgba(239, 68, 68, 0.8); + border: 0.5px solid rgba(220, 38, 38, 0.25); +} + +.category-type-badge.felgen { + background: rgba(168, 85, 247, 0.12); + color: rgba(192, 132, 252, 0.9); + border: 0.5px solid rgba(168, 85, 247, 0.25); +} + +/* ======================================== + Category Thumbnail + ======================================== */ +.category-thumbnail { + width: 40px; + height: 40px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + cursor: zoom-in; + border: 0.5px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; +} + +@media (min-width: 480px) { + .category-thumbnail { + width: 48px; + height: 48px; + border-radius: 10px; + } +} + +.category-thumbnail:hover { + border-color: rgba(168, 85, 247, 0.5); + transform: scale(1.05); +} + +.category-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ======================================== + File Upload Area + ======================================== */ +.file-upload-area { + margin-top: 4px; +} + +.upload-label { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + background: rgba(0, 0, 0, 0.2); + border: 1.5px dashed rgba(255, 255, 255, 0.15); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + min-height: 80px; +} + +.upload-label:hover { + background: rgba(168, 85, 247, 0.05); + border-color: rgba(168, 85, 247, 0.3); +} + +.upload-label input[type="file"], +.upload-label ::deep input[type="file"] { + display: none; +} + +.upload-icon { + font-family: "Apple Color Emoji", "Segoe UI Emoji", sans-serif; + font-size: 1.5rem; + line-height: 1; +} + +.upload-text { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + text-align: center; +} + +@media (min-width: 480px) { + .upload-text { + font-size: 0.85rem; + } +} + +.image-preview { + position: relative; + display: inline-block; + border-radius: 10px; + overflow: hidden; + border: 0.5px solid rgba(255, 255, 255, 0.15); +} + +.image-preview img { + display: block; + max-width: 100%; + max-height: 200px; + object-fit: contain; + border-radius: 10px; +} + +.image-remove { + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + min-width: 24px; + font-size: 0.75rem; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.image-remove:hover { + background: rgba(239, 68, 68, 0.8); +} + +/* ======================================== + Item Count Controls + ======================================== */ +.item-count-input { + background: rgba(0, 0, 0, 0.2); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 12px 14px; + margin-bottom: 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +@media (min-width: 480px) { + .item-count-input { + padding: 14px 16px; + border-radius: 12px; + margin-bottom: 1.5rem; + } +} + +.item-count-input label { + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); + font-weight: 500; + flex-shrink: 0; +} + +@media (min-width: 480px) { + .item-count-input label { + font-size: 0.9rem; + } +} + +.count-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.count-btn { + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + border: 0.5px solid rgba(255, 255, 255, 0.15); + color: #ffffff; + font-family: 'Rajdhani', sans-serif; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + min-height: 36px; +} + +@media (min-width: 480px) { + .count-btn { + width: 40px; + height: 40px; + border-radius: 10px; + font-size: 1.2rem; + min-height: 40px; + } +} + +.count-btn:hover:not(:disabled) { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); +} + +.count-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.count-field { + width: 60px; + text-align: center; + font-family: 'Rajdhani', sans-serif; + font-weight: 700; + font-size: 1.1rem; + padding: 6px; +} + +@media (min-width: 480px) { + .count-field { + width: 70px; + font-size: 1.2rem; + } +} + +.stack-count-toggle { + font-size: 0.75rem !important; + padding: 2px 6px !important; + min-height: auto !important; + white-space: nowrap; +} + +@media (min-width: 480px) { + .stack-count-toggle { + font-size: 0.8rem !important; + } +} + +/* ======================================== + Image Zoom Overlay + ======================================== */ +.image-zoom-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.image-zoom-container { + background: rgba(28, 28, 30, 0.95); + border: 0.5px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + overflow: hidden; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: zoomIn 0.25s ease; +} + +@media (min-width: 480px) { + .image-zoom-container { + border-radius: 20px; + max-width: 80vw; + max-height: 85vh; + } +} + +@media (min-width: 768px) { + .image-zoom-container { + max-width: 700px; + } +} + +@keyframes zoomIn { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.zoom-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.3); +} + +.zoom-title { + font-family: 'Rajdhani', sans-serif; + font-weight: 600; + font-size: 1rem; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (min-width: 480px) { + .zoom-title { + font-size: 1.1rem; + } +} + +.zoom-close { + width: 32px; + height: 32px; + min-width: 32px; + font-size: 0.9rem; + flex-shrink: 0; +} + +.zoom-image-wrapper { + padding: 16px; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 480px) { + .zoom-image-wrapper { + padding: 20px; + } +} + +.zoom-image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + border-radius: 8px; + user-select: none; + -webkit-user-select: none; +} + +@media (min-width: 480px) { + .zoom-image { + max-height: 75vh; + } +} diff --git a/Lagerbestand/Components/Pages/TireCheckout.razor b/Lagerbestand/Components/Pages/TireCheckout.razor new file mode 100644 index 0000000..8152e4e --- /dev/null +++ b/Lagerbestand/Components/Pages/TireCheckout.razor @@ -0,0 +1,444 @@ +@page "/tire-checkout" +@using Lagerbestand.Components.Shared +@implements IDisposable +@inject ICustomerService CustomerService +@inject ITireStackService TireStackService +@inject UserSessionService UserSession + +Reifenstapel Abholen - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsWorkerOrAdmin) +{ + +} +else +{ +
+ + + @* Search Section *@ +
+

Suche

+
+ + +
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + @if (!string.IsNullOrEmpty(successMessage)) + { +
@successMessage
+ } +
+ + @* Scanned Items List *@ + @if (scannedTireStacks.Count > 0) + { +
+
+

+ @scannedTireStacks.Count + Reifenstapel zum Abholen +

+ +
+ + + +
+ @foreach (var ts in scannedTireStacks) + { +
+
+
+ @ts.TicketNumber + @if (ts.StorageLocation != null) + { + + } + else + { + � - + } +
+ +
+ +
+
+ @if (ts.Customer != null) + { + @ts.Customer.FullName + @if (ts.Vehicle != null) + { + @ts.Vehicle.ShortName + } + } + else if (ts.CatalogItem != null) + { + 📦 @ts.CatalogItem.Name + } + else + { + 📦 Lagerbestand + } +
+ +
+ 1 Stapel (@TireStack.TiresPerStack Reifen) + + @(ts.Season == TireSeason.Sommer ? "☀�" : ts.Season == TireSeason.Winter ? "��" : "🔄") + + @if (!string.IsNullOrEmpty(ts.TireSize)) + { + @ts.TireSize + } + @if (ts.WithRims) + { + mit Felgen + } +
+ + @if (!string.IsNullOrEmpty(ts.TireBrand)) + { +
+ @ts.TireBrand @ts.TireModel +
+ } + +
+ Eingelagert: @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy") + (@ts.DaysStored Tage) +
+
+
+ } +
+ +
+ +
+
+ } + + @* Search Results *@ + @if (searchResults.Count > 0) + { +
+

+ @searchResults.Count Ergebnis(se) +

+ +
+ @foreach (var ts in searchResults) + { + var isAlreadyScanned = scannedTireStacks.Any(s => s.Id == ts.Id); +
+
+ @ts.TicketNumber + + @if (isAlreadyScanned) + { + ✓ Ausgewählt + } + else + { + @ts.StatusText + } + +
+
+
+ @if (ts.Customer != null) + { + @ts.Customer.FullName + @if (ts.Vehicle != null) + { + @ts.Vehicle.ShortName + } + } + else + { + 📦 @(ts.CatalogItem?.Name ?? "Lagerbestand") + } +
+
+ 1 Stapel (@TireStack.TiresPerStack Reifen) + @ts.Season @(ts.TireSize != null ? $"| {ts.TireSize}" : "") +
+
+ @if (ts.StorageLocation != null) + { + + } + else + { + � Kein Lagerplatz + } + Seit @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy") +
+
+
+ } +
+
+ } + + @* Success Modal *@ + @if (showSuccess) + { +
+
+
✓
+

Abholung erfolgreich!

+ + @if (pickedUpTireStacks.Count == 1) + { +

@pickedUpTireStacks[0].TicketNumber wurde abgeholt.

+

1 Stapel (@TireStack.TiresPerStack Reifen) abgeholt

+ } + else + { +

@pickedUpTireStacks.Count Reifenstapel wurden abgeholt.

+

@(pickedUpTireStacks.Count * TireStack.TiresPerStack) Reifen insgesamt

+ +
+ @foreach (var ts in pickedUpTireStacks) + { +
+ @ts.TicketNumber + 1 Stapel | @(ts.Customer?.FullName ?? ts.CatalogItem?.Name ?? "Lagerbestand") +
+ } +
+ } + +

+ @if (freedLocations.Count == 1) + { + Lagerplatz @freedLocations[0] ist wieder frei. + } + else if (freedLocations.Count > 1) + { + @freedLocations.Count Lagerplätze sind wieder frei. + } +

+ + +
+
+ } +
+} + +@code { + private bool isProcessing; + private bool showSuccess; + private string searchTerm = ""; + private string? errorMessage; + private string? successMessage; + + private List searchResults = new(); + private List scannedTireStacks = new(); + private List pickedUpTireStacks = new(); + private List 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(); + } +} diff --git a/Lagerbestand/Components/Pages/TireCheckout.razor.css b/Lagerbestand/Components/Pages/TireCheckout.razor.css new file mode 100644 index 0000000..d79dd43 --- /dev/null +++ b/Lagerbestand/Components/Pages/TireCheckout.razor.css @@ -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) */ diff --git a/Lagerbestand/Components/Pages/TireStorage.razor b/Lagerbestand/Components/Pages/TireStorage.razor new file mode 100644 index 0000000..84ee90a --- /dev/null +++ b/Lagerbestand/Components/Pages/TireStorage.razor @@ -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 + +Lagerübersicht - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsWorkerOrAdmin) +{ + +} +else +{ +
+ + + @* Statistics *@ +
+
+ @stats.TotalStacksStored + Stapel (@stats.TotalTiresStored Reifen) +
+
+ @stats.CustomerStackCount + Kundenstapel +
+
+ @stats.InventoryStackCount + Lagerbestand +
+
+ @stats.AvailableLocations + Freie Plätze +
+
+ + @* Type Filter (Customer vs Inventory) *@ + + + @* View Toggle (List / Rack Grid) *@ + + + @* Search & Filter *@ +
+ + + Durchsucht Namen, Fahrzeuge, Kategorien und Ticket-Nummern + +
+ Nach Saison filtern + + + + +
+
+ + @* Category Filter (only when showing inventory) *@ + @if (typeFilter == true && categories.Count > 0) + { +
+ + +
+ } + + @* Tire Stacks List *@ + @if (isLoading) + { +
+ + Lade Daten... +
+ } + else if (showGridView) + { +
+ @foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager }) + { +
+

@area.GetBuildingLabel()

+ @for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++) + { + var currentRow = row; +
+

Reihe @currentRow

+
+ @foreach (var side in new[] { "Links", "Rechts" }) + { +
+ @side + @foreach (var pos in ShelfPositionExtensions.All.Where(p => p.IsLeft() == (side == "Links")).OrderByDescending(p => p.IsTop())) + { +
+ @(pos.IsTop() ? "Oben" : "Unten") +
+ @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"; + + } +
+
+ } +
+ } +
+
+ } +
+ } + + + + @if (selectedLocation != null) + { + + } +
+ } + else if (filteredTireStacks.Count == 0) + { +
+ +

Keine Einträge

+

@(string.IsNullOrEmpty(searchTerm) ? "Noch keine Reifenstapel eingelagert." : "Keine Ergebnisse für Ihre Suche.")

+ @if (string.IsNullOrEmpty(searchTerm)) + { + Reifenstapel einlagern + } +
+ } + else + { +
+ @foreach (var ts in filteredTireStacks) + { +
+
+
+
+ @(ts.Season == TireSeason.Sommer ? "☀�" : ts.Season == TireSeason.Winter ? "��" : "🔄") +
+
+ @if (ts.IsInventory) + { + + @(ts.CatalogItem?.Name ?? "Lagerbestand") + + } + else + { + @ts.Customer?.FullName + } + 1 Stapel (@TireStack.TiresPerStack Reifen) @(ts.TireSize != null ? $"| {ts.TireSize}" : "") @(ts.WithRims ? "| mit Felgen" : "") +
+
+
+ @(ts.StorageLocation?.Code ?? "-") + @if (!ts.IsInventory && ts.Vehicle != null) + { + @ts.Vehicle.ShortName + } + @if (ts.IsInventory) + { + Lager + } +
+
+ + @if (expandedId == ts.Id) + { +
+
+
+
Ticket-Nr.
+
@ts.TicketNumber
+
+
+
Reifenstapel
+
1 Stapel (@TireStack.TiresPerStack Reifen)
+
+ @if (ts.IsInventory && ts.CatalogItem != null) + { +
+
Kategorie
+
@ts.CatalogItem.Name
+
+ } +
+
Marke
+
@(ts.TireBrand ?? "-")
+
+
+
Modell
+
@(ts.TireModel ?? "-")
+
+
+
Zustand
+
@ts.ConditionText
+
+
+
Eingelagert
+
+
+
+
Dauer
+
@ts.DaysStored Tage
+
+
+ @if (!string.IsNullOrEmpty(ts.Notes)) + { +
+ Notizen: + @ts.Notes +
+ } + +
+ } +
+ } +
+ } + + @* Expiring Pickups Warning (only for customer stacks) *@ + @if (expiringPickups.Count > 0) + { + + } +
+} + +@code { + private List allTireStacks = new(); + private List filteredTireStacks = new(); + private List expiringPickups = new(); + private List 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 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? _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"; + } +} diff --git a/Lagerbestand/Components/Pages/TireStorage.razor.css b/Lagerbestand/Components/Pages/TireStorage.razor.css new file mode 100644 index 0000000..582d5a4 --- /dev/null +++ b/Lagerbestand/Components/Pages/TireStorage.razor.css @@ -0,0 +1,1197 @@ +/* ======================================== + Tire Storage Overview Page + ======================================== */ + +.storage-page { + min-height: 100vh; + min-height: 100dvh; + background: var(--color-bg-gradient); + padding: 0.75rem; + padding-bottom: calc(1rem + var(--safe-area-bottom)); +} + +@media (min-width: 480px) { + .storage-page { + padding: 1rem; + } +} + +@media (min-width: 768px) { + .storage-page { + padding: 2rem 3rem; + max-width: 1200px; + margin: 0 auto; + } +} + +@media (min-width: 1024px) { + .storage-page { + padding: 2.5rem 4rem; + } +} + +/* ======================================== + Page Header - Responsive + ======================================== */ +.page-header { + margin-bottom: 1rem; +} + +@media (min-width: 480px) { + .page-header { + margin-bottom: 1.5rem; + } +} + +.header-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +@media (min-width: 480px) { + .header-content { + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 1rem; + } +} + +.page-header h1 { + font-family: var(--font-family-heading); + font-size: clamp(1.25rem, 4vw, 1.75rem); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0; +} + +.header-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 479.98px) { + .header-actions { + width: 100%; + } + + .header-actions .btn { + flex: 1; + justify-content: center; + min-width: 0; + } +} + +/* ======================================== + Statistics Grid - Responsive + ======================================== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 1rem; +} + +@media (min-width: 480px) { + .stats-grid { + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 1.5rem; + } +} + +@media (min-width: 768px) { + .stats-grid { + gap: 16px; + } +} + +@media (min-width: 1024px) { + .stats-grid { + gap: 20px; + } +} + +.stat-card { + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 12px; + text-align: center; +} + +@media (min-width: 480px) { + .stat-card { + padding: 14px; + border-radius: 14px; + } +} + +@media (min-width: 768px) { + .stat-card { + padding: 20px; + } +} + +@media (min-width: 1024px) { + .stat-card { + padding: 24px; + border-radius: 16px; + } +} + +.stat-value { + display: block; + font-family: 'Orbitron', monospace; + font-size: clamp(1.25rem, 4vw, 2.25rem); + font-weight: 700; + color: #ffffff; + margin-bottom: 4px; +} + +.stat-value.summer { + color: #fbbf24; +} + +.stat-value.winter { + color: #60a5fa; +} + +.stat-label { + font-family: 'Rajdhani', sans-serif; + font-size: clamp(0.65rem, 2vw, 0.85rem); + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ======================================== + Type Filter - Responsive + ======================================== */ +.type-filter { + display: flex; + gap: 6px; + margin-bottom: 0.75rem; + background: rgba(28, 28, 30, 0.6); + padding: 5px; + border-radius: 10px; +} + +@media (min-width: 480px) { + .type-filter { + gap: 8px; + padding: 6px; + border-radius: 12px; + margin-bottom: 1rem; + } +} + +.type-btn { + flex: 1; + padding: 8px 10px; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.5); + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + min-height: 40px; +} + +@media (min-width: 480px) { + .type-btn { + padding: 10px 14px; + font-size: 0.85rem; + border-radius: 8px; + min-height: 44px; + } +} + +.type-btn:hover { + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.05); +} + +.type-btn.active { + background: rgba(220, 38, 38, 0.15); + border: 1px solid rgba(220, 38, 38, 0.3); + color: #ffffff; +} + +/* ======================================== + Filter Bar - Responsive + ======================================== */ +.filter-bar { + display: flex; + gap: 8px; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +@media (min-width: 480px) { + .filter-bar { + gap: 10px; + margin-bottom: 1.5rem; + } +} + +@media (min-width: 768px) { + .filter-bar { + gap: 14px; + } +} + +.search-input { + flex: 1; + min-width: 0; + width: 100%; + padding: 12px 14px; + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + color: #ffffff; + font-family: 'Rajdhani', sans-serif; + font-size: 16px; /* Prevents iOS zoom */ +} + +@media (min-width: 480px) { + .search-input { + min-width: 200px; + border-radius: 12px; + } +} + +@media (min-width: 768px) { + .search-input { + max-width: 400px; + padding: 14px 18px; + font-size: 1rem; + } +} + +.search-input:focus { + outline: none; + border-color: rgba(220, 38, 38, 0.5); +} + +.search-input::placeholder { + color: rgba(255, 255, 255, 0.35); +} + +.filter-buttons { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +@media (max-width: 479.98px) { + .filter-buttons { + width: 100%; + justify-content: flex-start; + } +} + +.filter-btn { + padding: 10px 12px; + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + min-height: 44px; +} + +@media (min-width: 480px) { + .filter-btn { + padding: 10px 14px; + border-radius: 10px; + font-size: 0.9rem; + } +} + +.filter-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +.filter-btn.active { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); + color: #ffffff; +} + +.filter-btn.active.summer { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.4); +} + +.filter-btn.active.winter { + background: rgba(96, 165, 250, 0.15); + border-color: rgba(96, 165, 250, 0.4); +} + +.filter-btn.active.allseason { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); +} + +/* ======================================== + Loading & Empty States - Responsive + ======================================== */ +.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-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 2.5rem; + margin-bottom: 0.75rem; + line-height: 1; +} + +@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 0 1.5rem 0; + font-size: 0.9rem; +} + +/* ======================================== + Tire Set List - Responsive + ======================================== */ +.tireset-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +@media (min-width: 480px) { + .tireset-list { + gap: 10px; + } +} + +@media (min-width: 768px) { + .tireset-list { + gap: 12px; + } +} + +.tireset-card { + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease; +} + +@media (min-width: 480px) { + .tireset-card { + border-radius: 14px; + } +} + +@media (min-width: 768px) { + .tireset-card { + border-radius: 16px; + } +} + +.tireset-card:hover { + border-color: rgba(255, 255, 255, 0.2); +} + +.tireset-main { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + gap: 10px; +} + +@media (min-width: 480px) { + .tireset-main { + padding: 14px 16px; + gap: 12px; + } +} + +@media (min-width: 768px) { + .tireset-main { + padding: 18px 24px; + } +} + +.tireset-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; +} + +@media (min-width: 480px) { + .tireset-left { + gap: 12px; + } +} + +.season-indicator { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + font-size: 1.1rem; + background: rgba(255, 255, 255, 0.05); + flex-shrink: 0; + line-height: 1; +} + +@media (min-width: 480px) { + .season-indicator { + width: 40px; + height: 40px; + border-radius: 10px; + font-size: 1.25rem; + } +} + +@media (min-width: 768px) { + .season-indicator { + width: 48px; + height: 48px; + font-size: 1.5rem; + } +} + +.season-indicator.sommer { + background: rgba(251, 191, 36, 0.15); +} + +.season-indicator.winter { + background: rgba(96, 165, 250, 0.15); +} + +.season-indicator.allrad { + background: rgba(34, 197, 94, 0.15); +} + +.tireset-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.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; + } +} + +@media (min-width: 768px) { + .customer-name { + font-size: 1.1rem; + } +} + +.tire-details { + 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) { + .tire-details { + font-size: 0.85rem; + } +} + +@media (min-width: 768px) { + .tire-details { + font-size: 0.9rem; + } +} + +.tireset-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} + +.location-code { + font-family: 'Orbitron', monospace; + font-weight: 600; + color: #60a5fa; + font-size: 0.85rem; +} + +@media (min-width: 480px) { + .location-code { + font-size: 0.95rem; + } +} + +@media (min-width: 768px) { + .location-code { + font-size: 1.05rem; + } +} + +.vehicle-info { + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.6); +} + +@media (min-width: 480px) { + .vehicle-info { + padding: 2px 8px; + font-size: 0.75rem; + } +} + +/* ======================================== + Tire Set Details (Expanded) - Responsive + ======================================== */ +.tireset-details { + padding: 0 14px 14px; + border-top: 0.5px solid rgba(255, 255, 255, 0.05); + animation: slideDown 0.2s ease; +} + +@media (min-width: 480px) { + .tireset-details { + padding: 0 16px 16px; + } +} + +@media (min-width: 768px) { + .tireset-details { + padding: 0 24px 20px; + } +} + +@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; + } +} + +@media (min-width: 768px) { + .detail-grid { + grid-template-columns: repeat(3, 1fr); + gap: 16px; + padding: 18px 0; + } +} + +@media (min-width: 1024px) { + .detail-grid { + grid-template-columns: repeat(6, 1fr); + } +} + +.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; +} + +@media (min-width: 480px) { + .detail-item .value { + font-size: 0.9rem; + } +} + +.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); +} + +.detail-actions { + display: flex; + justify-content: flex-end; + padding-top: 12px; + border-top: 0.5px solid rgba(255, 255, 255, 0.05); +} + +/* Buttons: inherited from app.css (section 5) */ + +/* ======================================== + Expiring Section - Responsive + ======================================== */ +.expiring-section { + margin-top: 1.5rem; + background: rgba(251, 191, 36, 0.1); + border: 0.5px solid rgba(251, 191, 36, 0.25); + border-radius: 14px; + padding: 14px; +} + +@media (min-width: 480px) { + .expiring-section { + margin-top: 2rem; + border-radius: 16px; + padding: 16px; + } +} + +.expiring-section h3 { + font-family: 'Rajdhani', sans-serif; + font-size: 0.95rem; + font-weight: 600; + color: #fbbf24; + margin: 0 0 12px 0; +} + +@media (min-width: 480px) { + .expiring-section h3 { + font-size: 1rem; + } +} + +.expiring-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.expiring-item { + display: flex; + justify-content: space-between; + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; +} + +@media (min-width: 480px) { + .expiring-item { + font-size: 0.9rem; + } +} + +.expiring-item .customer { + color: #ffffff; +} + +.expiring-item .date { + color: rgba(255, 255, 255, 0.5); +} + +/* ======================================== + Inventory Card Styling - Responsive + ======================================== */ +.tireset-card.inventory { + border-left: 3px solid rgba(96, 165, 250, 0.5); +} + +.inventory-name { + color: #93c5fd !important; +} + +.inventory-badge { + background: rgba(96, 165, 250, 0.15); + border: 1px solid rgba(96, 165, 250, 0.3); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.65rem; + color: #93c5fd; + font-weight: 600; + text-transform: uppercase; +} + +@media (min-width: 480px) { + .inventory-badge { + padding: 2px 8px; + font-size: 0.7rem; + } +} + +/* ======================================== + QR Section in Details - Responsive + ======================================== */ +.qr-section { + background: rgba(34, 197, 94, 0.08); + border: 0.5px solid rgba(34, 197, 94, 0.2); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 12px; +} + +.qr-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; +} + +.qr-codes { + font-family: 'Orbitron', monospace; + font-size: 0.75rem; + color: #86efac; + word-break: break-all; +} + +@media (min-width: 480px) { + .qr-codes { + font-size: 0.8rem; + } +} + +/* ======================================== + Category Filter - Responsive + ======================================== */ +.category-filter { + margin-bottom: 0.75rem; +} + +@media (min-width: 480px) { + .category-filter { + margin-bottom: 1rem; + } +} + +.category-filter .form-control { + width: 100%; + padding: 10px 14px; + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + color: #ffffff; + font-family: 'Rajdhani', sans-serif; + font-size: 16px; /* Prevents iOS zoom */ +} + +@media (min-width: 480px) { + .category-filter .form-control { + max-width: 300px; + } +} + +.category-filter .form-control:focus { + outline: none; + border-color: rgba(220, 38, 38, 0.5); +} + +.category-filter .form-control option { + background: #1c1c1e; + color: #ffffff; +} + +/* ======================================== + View Toggle (List / Rack Grid) + ======================================== */ +.view-toggle { + display: flex; + gap: 6px; + margin-bottom: 0.75rem; + background: rgba(28, 28, 30, 0.6); + padding: 5px; + border-radius: 10px; +} + +.view-btn { + flex: 1; + padding: 8px 10px; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.5); + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-height: 40px; +} + +.view-btn:hover { + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.05); +} + +.view-btn.active { + background: rgba(220, 38, 38, 0.15); + border: 1px solid rgba(220, 38, 38, 0.3); + color: #ffffff; +} + +/* ======================================== + Rack Grid View + ======================================== */ +.rack-view { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; +} + +/* Use the full page width when the rack view is active */ +@media (min-width: 768px) { + .storage-page:has(.rack-view) { + max-width: none; + padding: 2rem 2.5rem; + } +} + +@media (min-width: 1024px) { + .storage-page:has(.rack-view) { + padding: 2.5rem 3rem; + } +} + +/* Buildings side by side on very wide screens */ +@media (min-width: 1400px) { + .rack-view { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; + align-items: start; + } + + .rack-view .rack-legend, + .rack-view .cell-details { + grid-column: 1 / -1; + } +} + +.building-section { + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 1.25rem; + width: 100%; +} + +.building-title { + font-family: 'Rajdhani', sans-serif; + font-size: 1.2rem; + font-weight: 600; + color: #ffffff; + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.rack-row { + margin-bottom: 1rem; +} + +.row-title { + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.7); + margin: 0 0 0.4rem 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.rack-sides { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} + +@media (max-width: 767.98px) { + .rack-sides { + grid-template-columns: 1fr; + gap: 0.75rem; + } +} + +.rack-side { + min-width: 0; +} + +.side-label { + display: block; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 0.25rem; +} + +.rack-level { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.25rem 0; +} + +.level-label { + width: 3rem; + flex-shrink: 0; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.4); +} + +.rack-cells { + display: grid; + grid-template-columns: repeat(9, 1fr); + gap: 5px; + flex: 1; + min-width: 0; +} + +.rack-cell { + width: 100%; + min-height: 2.2rem; + border-radius: 6px; + border: 1px solid transparent; + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, box-shadow 0.15s ease; + padding: 0; +} + +@media (min-width: 1024px) { + .rack-cell { + min-height: 2.6rem; + font-size: 0.95rem; + } +} + +.rack-cell:not(:disabled):hover { + transform: scale(1.1); +} + +.rack-cell.free { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); + color: #4ade80; +} + +.rack-cell.occupied { + background: rgba(220, 38, 38, 0.2); + border-color: rgba(220, 38, 38, 0.45); + color: #f87171; +} + +.rack-cell.blocked { + background: rgba(148, 163, 184, 0.15); + border-color: rgba(148, 163, 184, 0.3); + color: rgba(255, 255, 255, 0.35); + cursor: not-allowed; +} + +.rack-cell.missing { + background: transparent; + border: 1px dashed rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.2); + cursor: default; +} + +.rack-cell.selected { + outline: 2px solid rgba(220, 38, 38, 0.8); + outline-offset: 1px; +} + +/* ======================================== + Rack Legend + ======================================== */ +.rack-legend { + display: flex; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); + font-family: 'Rajdhani', sans-serif; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.legend-swatch { + width: 0.9rem; + height: 0.9rem; + border-radius: 4px; + display: inline-block; +} + +.legend-swatch.free { + background: rgba(34, 197, 94, 0.4); +} + +.legend-swatch.occupied { + background: rgba(220, 38, 38, 0.45); +} + +.legend-swatch.blocked { + background: rgba(148, 163, 184, 0.35); +} + +/* ======================================== + Cell Details Panel + ======================================== */ +.cell-details { + background: rgba(28, 28, 30, 0.8); + border: 0.5px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 1rem; +} + +.cell-details-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.cell-details-text { + color: rgba(255, 255, 255, 0.6); + font-family: 'Rajdhani', sans-serif; + margin: 0.5rem 0; +} + +/* Reduced motion: inherited from app.css (section 17) */ diff --git a/Lagerbestand/Components/Pages/Users.razor b/Lagerbestand/Components/Pages/Users.razor new file mode 100644 index 0000000..abc1a0b --- /dev/null +++ b/Lagerbestand/Components/Pages/Users.razor @@ -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 + +Benutzerverwaltung - Reifen Späth Lagerverwaltung + + + +@if (!UserSession.IsAdmin) +{ + +} +else +{ +
+
+
+

Benutzerverwaltung

+

@users.Count Benutzer insgesamt · @users.Count(u => u.IsActive) aktiv

+
+
+ + +
+
+ + @if (isLoading) + { +
+
+

Lade Benutzer...

+
+ } + else if (!string.IsNullOrEmpty(errorMessage)) + { +
+ @errorMessage + +
+ } + else + { + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ @successMessage + +
+ } + +
+
+ Benutzername + Anzeigename + Rolle + 2FA + Status + Letzter Login + Aktionen +
+
+ @foreach (var user in users) + { + var isCurrentUser = user.Id == UserSession.CurrentUser?.Id; +
+ + @user.Username + @if (isCurrentUser) + { + Sie + } + + @(user.DisplayName ?? "-") + + @GetRoleName(user.Role) + + + @if (twoFactorGlobalEnabled) + { + @if (user.TwoFactorEnabled) + { + + ?? @(user.TwoFactorEnforced ? "Erzwungen" : "Aktiv") + + } + else + { + Aus + } + } + else + { + — + } + + + + @(user.IsActive ? "Aktiv" : "Inaktiv") + + + + @if (user.LastLoginAt.HasValue) + { + + @FormatLastLogin(user.LastLoginAt.Value) + + } + else + { + Nie + } + + + + + @if (twoFactorGlobalEnabled) + { + @if (user.TwoFactorEnabled) + { + + } + @if (!user.TwoFactorEnforced) + { + + } + else + { + + } + } + @if (!isCurrentUser) + { + + } + +
+ } +
+
+ + @if (users.Count == 0) + { +
+
??
+

Keine Benutzer gefunden

+

Erstellen Sie einen neuen Benutzer, um zu beginnen.

+
+ } + } +
+ + @* Create/Edit User Modal *@ + @if (showUserModal) + { + + + } + + @* Password Modal *@ + @if (showPasswordModal) + { + + + } + + @* Delete Confirmation Modal *@ + @if (showDeleteModal) + { + + + } + + @* 2FA Setup Modal *@ + @if (show2FaSetupModal) + { + + + } +} + +@code { + private List 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? _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 +} diff --git a/Lagerbestand/Components/Pages/Users.razor.css b/Lagerbestand/Components/Pages/Users.razor.css new file mode 100644 index 0000000..871055f --- /dev/null +++ b/Lagerbestand/Components/Pages/Users.razor.css @@ -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; + } +} diff --git a/Lagerbestand/Components/Pages/Weather.razor b/Lagerbestand/Components/Pages/Weather.razor new file mode 100644 index 0000000..e69de29 diff --git a/Lagerbestand/Components/Pages/Weather.razor.css b/Lagerbestand/Components/Pages/Weather.razor.css new file mode 100644 index 0000000..e69de29 diff --git a/Lagerbestand/Components/Routes.razor b/Lagerbestand/Components/Routes.razor new file mode 100644 index 0000000..95d3bfb --- /dev/null +++ b/Lagerbestand/Components/Routes.razor @@ -0,0 +1,8 @@ +@using Lagerbestand.Components.Layout + + + + + + + diff --git a/Lagerbestand/Components/Shared/LocationCodeInput.razor b/Lagerbestand/Components/Shared/LocationCodeInput.razor new file mode 100644 index 0000000..9358602 --- /dev/null +++ b/Lagerbestand/Components/Shared/LocationCodeInput.razor @@ -0,0 +1,451 @@ +@using Lagerbestand.Models +@using Lagerbestand.Services +@inject ILocationCodeService LocationCodeService + +
+

Stellplatz-Daten

+ +
+ @* Area Selection - Required *@ +
+ + + @if (areaError != null) + { + + ? @areaError + + } + else if (areaTouched) + { + + ? + + } +
+ + @* Row Selection - Required *@ +
+ + + @if (rowError != null) + { + + ? @rowError + + } + else if (rowTouched) + { + + ? + + } +
+ + @* Position Selection (Seite/Ebene) - Required *@ +
+ + +
+ + @* Slot Input - Required *@ +
+ + + @StorageLocation.MinSlot - @StorageLocation.MaxSlot + @if (slotError != null) + { + + ? @slotError + + } + else if (slotTouched) + { + + ? + + } +
+
+ + @* Location Code Preview - Prominent Display *@ +
+
+ Stellplatz-Code +
+
+ @(IsFormValid ? GeneratedCode : "----") +
+
+ @if (IsFormValid) + { + @DisplayText + } + else + { + Bitte alle Felder ausfüllen + } +
+ + @* Availability Status *@ + @if (ShowAvailability && IsFormValid) + { +
+ @if (isChecking) + { + + Verfügbarkeit wird geprüft... + } + else if (isAvailable) + { + ? + Verfügbar + } + else if (locationExists) + { + ? + Dieser Stellplatz ist bereits belegt + } + else + { + + + Neuer Stellplatz + } +
+ } +
+
+ +@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 SelectedAreaChanged { get; set; } + + [Parameter] + public int SelectedRow { get; set; } = 1; + + [Parameter] + public EventCallback SelectedRowChanged { get; set; } + + [Parameter] + public ShelfPosition SelectedPosition { get; set; } = ShelfPosition.LinksOben; + + [Parameter] + public EventCallback SelectedPositionChanged { get; set; } + + [Parameter] + public int SelectedSlot { get; set; } = 1; + + [Parameter] + public EventCallback SelectedSlotChanged { get; set; } + + [Parameter] + public string? LocationCode { get; set; } + + [Parameter] + public EventCallback LocationCodeChanged { get; set; } + + [Parameter] + public bool ShowAvailability { get; set; } = true; + + [Parameter] + public EventCallback OnAvailabilityChanged { get; set; } + + [Parameter] + public EventCallback 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(); + } + } +} diff --git a/Lagerbestand/Components/Shared/LocationCodeInput.razor.css b/Lagerbestand/Components/Shared/LocationCodeInput.razor.css new file mode 100644 index 0000000..40ed20e --- /dev/null +++ b/Lagerbestand/Components/Shared/LocationCodeInput.razor.css @@ -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) */ diff --git a/Lagerbestand/Components/Shared/LocationDisplay.razor b/Lagerbestand/Components/Shared/LocationDisplay.razor new file mode 100644 index 0000000..e2d08bc --- /dev/null +++ b/Lagerbestand/Components/Shared/LocationDisplay.razor @@ -0,0 +1,188 @@ +@using Lagerbestand.Models +@using Lagerbestand.Services +@inject ILocationCodeService LocationCodeService + +@if (!string.IsNullOrEmpty(Code)) +{ +
+ @if (ShowIcon) + { + @AreaIcon + } + +
+ @if (DisplayMode == LocationDisplayMode.Code) + { + @Code + } + else if (DisplayMode == LocationDisplayMode.Full) + { + @Code + @DisplayText + } + else if (DisplayMode == LocationDisplayMode.Text) + { + @DisplayText + } + else if (DisplayMode == LocationDisplayMode.Compact) + { + @ShortText + } +
+ + @if (ShowStatus) + { + + @(IsOccupied ? "?" : "?") + + } +
+} +else +{ + - +} + + + +@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; + } + } +} diff --git a/Lagerbestand/Components/Shared/LocationDisplayEnums.cs b/Lagerbestand/Components/Shared/LocationDisplayEnums.cs new file mode 100644 index 0000000..606d2fd --- /dev/null +++ b/Lagerbestand/Components/Shared/LocationDisplayEnums.cs @@ -0,0 +1,37 @@ +namespace Lagerbestand.Components.Shared; + +/// +/// Display modes for the LocationDisplay component. +/// +public enum LocationDisplayMode +{ + /// + /// Just the code "2235" + /// + Code, + + /// + /// Code + full text "2235" + "Lager, Reihe 2, Rechts oben, Platz 5" + /// + Full, + + /// + /// Full text only "Lager, Reihe 2, Rechts oben, Platz 5" + /// + Text, + + /// + /// Compact format "Lager R2 R↑ P5" + /// + Compact +} + +/// +/// Size variants for the LocationDisplay component. +/// +public enum LocationDisplaySize +{ + Small, + Medium, + Large +} diff --git a/Lagerbestand/Components/Shared/SeoMetaTags.razor b/Lagerbestand/Components/Shared/SeoMetaTags.razor new file mode 100644 index 0000000..cc10c00 --- /dev/null +++ b/Lagerbestand/Components/Shared/SeoMetaTags.razor @@ -0,0 +1,166 @@ +@* + SeoMetaTags Component + Renders SEO meta tags dynamically per page via HeadContent + Usage: +*@ + + + @* Primary Meta Tags *@ + @if (!string.IsNullOrEmpty(Description)) + { + + } + @if (!string.IsNullOrEmpty(Keywords)) + { + + } + @if (!string.IsNullOrEmpty(Robots)) + { + + + } + @if (!string.IsNullOrEmpty(Author)) + { + + } + + @* Canonical URL *@ + @if (!string.IsNullOrEmpty(CanonicalUrl)) + { + + + + } + + @* Open Graph Meta Tags *@ + @if (!string.IsNullOrEmpty(OgTitle ?? Title)) + { + + } + @if (!string.IsNullOrEmpty(OgDescription ?? Description)) + { + + } + @if (!string.IsNullOrEmpty(OgType)) + { + + } + @if (!string.IsNullOrEmpty(OgUrl ?? CanonicalUrl)) + { + + } + @if (!string.IsNullOrEmpty(OgImage)) + { + + @if (OgImageWidth > 0) + { + + } + @if (OgImageHeight > 0) + { + + } + @if (!string.IsNullOrEmpty(OgImageAlt)) + { + + } + + } + @if (!string.IsNullOrEmpty(OgLocale)) + { + + } + @if (!string.IsNullOrEmpty(OgSiteName)) + { + + } + + @* Twitter Card Meta Tags *@ + @if (!string.IsNullOrEmpty(TwitterCard)) + { + + } + @if (!string.IsNullOrEmpty(TwitterTitle ?? OgTitle ?? Title)) + { + + } + @if (!string.IsNullOrEmpty(TwitterDescription ?? OgDescription ?? Description)) + { + + } + @if (!string.IsNullOrEmpty(TwitterImage ?? OgImage)) + { + + @if (!string.IsNullOrEmpty(TwitterImageAlt ?? OgImageAlt)) + { + + } + } + + @* Article-specific metadata (for blog posts, news articles) *@ + @if (!string.IsNullOrEmpty(ArticlePublishedTime)) + { + + } + @if (!string.IsNullOrEmpty(ArticleModifiedTime)) + { + + } + @if (!string.IsNullOrEmpty(ArticleAuthor)) + { + + } + + @* Structured Data (JSON-LD) *@ + @if (!string.IsNullOrEmpty(JsonLd)) + { + + } + + +@code { + /// Page title (used as fallback for OG/Twitter) + [Parameter] public string? Title { get; set; } + + /// Meta description for search engines (max 160 chars recommended) + [Parameter] public string? Description { get; set; } + + /// Comma-separated keywords + [Parameter] public string? Keywords { get; set; } + + /// Robots directive (e.g., "index, follow" or "noindex, nofollow") + [Parameter] public string? Robots { get; set; } = "index, follow"; + + /// Author name + [Parameter] public string? Author { get; set; } + + /// Canonical URL for the page + [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; } + + /// JSON-LD structured data string + [Parameter] public string? JsonLd { get; set; } +} diff --git a/Lagerbestand/Components/Shared/TireSizeInput.razor b/Lagerbestand/Components/Shared/TireSizeInput.razor new file mode 100644 index 0000000..c58dee1 --- /dev/null +++ b/Lagerbestand/Components/Shared/TireSizeInput.razor @@ -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. *@ + + + +@code { + [Parameter] public string? Value { get; set; } + [Parameter] public EventCallback 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); + } + + /// + /// Formats digit input into the tire size mask ###/## R##. + /// Examples: "2254517" -> "225/45 R17", "22545" -> "225/45", "225" -> "225" + /// + 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..]}"; + } +} diff --git a/Lagerbestand/Components/Shared/VisibilityRefresh.cs b/Lagerbestand/Components/Shared/VisibilityRefresh.cs new file mode 100644 index 0000000..c7dd2f1 --- /dev/null +++ b/Lagerbestand/Components/Shared/VisibilityRefresh.cs @@ -0,0 +1,43 @@ +using Microsoft.JSInterop; + +namespace Lagerbestand.Components.Shared; + +/// +/// Helper that wires a page's reload method to the browser's +/// visibilitychange event via appInterop.registerVisibilityRefresh. +/// The caller owns the passed in. +/// +public static class VisibilityRefresh +{ + /// + /// Registers a .NET callback invoked when the browser tab becomes + /// visible again after being hidden for at least . + /// + public static async Task RegisterAsync( + IJSRuntime js, + DotNetObjectReference 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. + } + } + + /// + /// Unregisters the visibility callback. Safe to call multiple times. + /// + public static async Task UnregisterAsync(IJSRuntime js) + { + try + { + await js.InvokeVoidAsync("appInterop.unregisterVisibilityRefresh"); + } + catch { /* ignore */ } + } +} diff --git a/Lagerbestand/Components/_Imports.razor b/Lagerbestand/Components/_Imports.razor new file mode 100644 index 0000000..055dbe8 --- /dev/null +++ b/Lagerbestand/Components/_Imports.razor @@ -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 diff --git a/Lagerbestand/Data/AppDbContext.cs b/Lagerbestand/Data/AppDbContext.cs new file mode 100644 index 0000000..febf96a --- /dev/null +++ b/Lagerbestand/Data/AppDbContext.cs @@ -0,0 +1,496 @@ +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet CatalogItems => Set(); + public DbSet InventoryItems => Set(); + public DbSet Users => Set(); + public DbSet Customers => Set(); + public DbSet Vehicles => Set(); + public DbSet StorageLocations => Set(); + public DbSet TireStacks => Set(); + public DbSet TireItems => Set(); + public DbSet SiteSettings => Set(); + + // Kanban entities + public DbSet KanbanColumns => Set(); + public DbSet KanbanLabels => Set(); + public DbSet KanbanTasks => Set(); + public DbSet KanbanTaskTodos => Set(); + public DbSet KanbanTaskComments => Set(); + public DbSet KanbanTaskNotes => Set(); + public DbSet KanbanTaskAttachments => Set(); + public DbSet KanbanTaskTemplates => Set(); + public DbSet KanbanTaskTemplateTodos => Set(); + public DbSet KanbanTaskLabels => Set(); + public DbSet KanbanTaskAssignees => Set(); + public DbSet KanbanTaskCustomers => Set(); + public DbSet KanbanTaskTireStacks => Set(); + public DbSet KanbanTaskTemplateLabels => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // CatalogItem configuration + modelBuilder.Entity(entity => + { + entity.ToTable("CatalogItems"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(200); + entity.Property(e => e.PictureFileName).HasMaxLength(500); + entity.Property(e => e.TireSize).HasMaxLength(50); + entity.Property(e => e.Season).HasConversion(); + entity.Property(e => e.Type).HasConversion().HasDefaultValue(CatalogItemType.Reifen); + }); + + // InventoryItem configuration (legacy) + modelBuilder.Entity(entity => + { + entity.ToTable("InventoryItems"); + entity.HasKey(e => e.Id); + entity.Property(e => e.QrCode).IsRequired().HasMaxLength(500); + entity.Property(e => e.Name).HasMaxLength(200); + entity.Property(e => e.Description).HasMaxLength(500); + entity.HasIndex(e => e.QrCode); + entity.HasOne(e => e.CatalogItem) + .WithMany() + .HasForeignKey(e => e.CatalogItemId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // AppUser configuration + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Username).IsRequired().HasMaxLength(100); + entity.Property(e => e.PasswordHash).IsRequired().HasMaxLength(256); + entity.Property(e => e.DisplayName).HasMaxLength(200); + entity.Property(e => e.Role).HasConversion(); + entity.HasIndex(e => e.Username).IsUnique(); + entity.Property(e => e.TwoFactorSecret).HasMaxLength(256); + }); + + // Customer configuration + modelBuilder.Entity(entity => + { + entity.ToTable("Customers"); + entity.HasKey(e => e.Id); + entity.Property(e => e.FirstName).IsRequired().HasMaxLength(100); + entity.Property(e => e.LastName).IsRequired().HasMaxLength(100); + entity.Property(e => e.Company).HasMaxLength(200); + entity.Property(e => e.Phone).HasMaxLength(20); + entity.Property(e => e.Email).HasMaxLength(100); + entity.Property(e => e.Notes).HasMaxLength(500); + entity.Property(e => e.CustomerNumber).IsRequired().HasMaxLength(20); + + entity.HasIndex(e => e.CustomerNumber).IsUnique(); + entity.HasIndex(e => new { e.LastName, e.FirstName }); + }); + + // Vehicle configuration + modelBuilder.Entity(entity => + { + entity.ToTable("Vehicles"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Make).IsRequired().HasMaxLength(100); + entity.Property(e => e.Model).HasMaxLength(100); + entity.Property(e => e.TireSize).IsRequired().HasMaxLength(50); + entity.Property(e => e.Notes).HasMaxLength(500); + + entity.HasIndex(e => e.CustomerId); + entity.HasIndex(e => new { e.Make, e.Model }); + entity.HasIndex(e => new { e.CustomerId, e.IsPrimary }); + + entity.HasOne(e => e.Customer) + .WithMany(c => c.Vehicles) + .HasForeignKey(e => e.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // StorageLocation configuration - ABCD code format + modelBuilder.Entity(entity => + { + entity.ToTable("StorageLocations"); + entity.HasKey(e => e.Id); + + // Code is the 4-digit ABCD format (e.g., "2235") + entity.Property(e => e.Code).IsRequired().HasMaxLength(4); + + // Area enum stored as int (1=Werkstatt, 2=Lager) + entity.Property(e => e.Area).HasConversion(); + + // Row is an int (Werkstatt: 1-3, Lager: 1-5) + entity.Property(e => e.Row).IsRequired(); + + // Position enum stored as int (1=links oben ... 4=rechts unten) + entity.Property(e => e.Position).HasConversion().IsRequired(); + + // SlotNumber is int (1-9) + entity.Property(e => e.SlotNumber).IsRequired(); + + // Concurrency token: prevents double-booking when two requests + // try to occupy/clear the same location simultaneously + entity.Property(e => e.IsOccupied).IsConcurrencyToken(); + + entity.Property(e => e.Notes).HasMaxLength(500); + + // Indexes + entity.HasIndex(e => e.Code).IsUnique(); + entity.HasIndex(e => new { e.Area, e.Row }); + entity.HasIndex(e => new { e.Area, e.Row, e.Position, e.SlotNumber }).IsUnique(); + entity.HasIndex(e => e.IsOccupied); + entity.HasIndex(e => e.TireStackId); + + // Relationship to the occupied TireStack + entity.HasOne(e => e.OccupiedTireStack) + .WithOne() + .HasForeignKey(e => e.TireStackId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // TireStack configuration + modelBuilder.Entity(entity => + { + entity.ToTable("TireStacks"); + entity.HasKey(e => e.Id); + entity.Property(e => e.TicketNumber).IsRequired().HasMaxLength(50); + entity.Property(e => e.TireSize).HasMaxLength(50); + entity.Property(e => e.TireBrand).HasMaxLength(100); + entity.Property(e => e.TireModel).HasMaxLength(100); + entity.Property(e => e.DotCode).HasMaxLength(20); + entity.Property(e => e.RimSize).HasMaxLength(20); + entity.Property(e => e.Notes).HasMaxLength(1000); + entity.Property(e => e.Status).HasConversion(); + entity.Property(e => e.Season).HasConversion(); + entity.Property(e => e.Condition).HasConversion(); + entity.Property(e => e.TreadDepth).HasPrecision(4, 1); + entity.Property(e => e.ItemCount).HasDefaultValue(TireStack.TiresPerStack); + + // CustomerId is optional (nullable) for inventory items without a customer + entity.Property(e => e.CustomerId).IsRequired(false); + entity.Property(e => e.VehicleId).IsRequired(false); + + entity.HasIndex(e => e.TicketNumber).IsUnique(); + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.CustomerId); + entity.HasIndex(e => e.VehicleId); + entity.HasIndex(e => e.StorageLocationId); + + // Customer relationship - optional for inventory items + entity.HasOne(e => e.Customer) + .WithMany(c => c.TireStacks) + .HasForeignKey(e => e.CustomerId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + // Vehicle relationship - optional + entity.HasOne(e => e.Vehicle) + .WithMany(v => v.TireStacks) + .HasForeignKey(e => e.VehicleId) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(e => e.StorageLocation) + .WithMany(s => s.TireStacks) + .HasForeignKey(e => e.StorageLocationId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(e => e.CatalogItem) + .WithMany() + .HasForeignKey(e => e.CatalogItemId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // TireItem configuration + modelBuilder.Entity(entity => + { + entity.ToTable("TireItems"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Position).HasConversion(); + entity.Property(e => e.Condition).HasConversion(); + entity.Property(e => e.TreadDepth).HasPrecision(4, 1); + entity.Property(e => e.DamageNotes).HasMaxLength(500); + entity.Property(e => e.DotCode).HasMaxLength(20); + entity.Property(e => e.PhotoFileName).HasMaxLength(500); + + entity.HasIndex(e => e.TireStackId); + + entity.HasOne(e => e.TireStack) + .WithMany() + .HasForeignKey(e => e.TireStackId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // SiteSettings configuration + modelBuilder.Entity(entity => + { + entity.ToTable("SiteSettings"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Key).IsRequired().HasMaxLength(100); + entity.Property(e => e.Value).IsRequired().HasMaxLength(4000); + entity.HasIndex(e => e.Key).IsUnique(); + }); + + // ======================================== + // Kanban Board Configurations + // ======================================== + + // KanbanColumn configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanColumns"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Color).HasMaxLength(7); + entity.Property(e => e.Icon).HasMaxLength(10); + entity.HasIndex(e => e.SortOrder); + }); + + // KanbanLabel configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanLabels"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(50); + entity.Property(e => e.Color).IsRequired().HasMaxLength(7); + entity.Property(e => e.Description).HasMaxLength(200); + entity.HasIndex(e => e.SortOrder); + }); + + // KanbanTask configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTasks"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Title).IsRequired().HasMaxLength(200); + entity.Property(e => e.Priority).HasConversion(); + + entity.HasIndex(e => e.ColumnId); + entity.HasIndex(e => e.DueDate); + entity.HasIndex(e => e.IsArchived); + entity.HasIndex(e => new { e.ColumnId, e.SortOrder }); + + entity.HasOne(e => e.Column) + .WithMany(c => c.Tasks) + .HasForeignKey(e => e.ColumnId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.CreatedByUser) + .WithMany() + .HasForeignKey(e => e.CreatedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // KanbanTaskTodo configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskTodos"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Text).IsRequired().HasMaxLength(500); + + entity.HasIndex(e => e.TaskId); + + entity.HasOne(e => e.Task) + .WithMany(t => t.Todos) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // KanbanTaskComment configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskComments"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Content).IsRequired(); + entity.Property(e => e.CommentType).HasConversion(); + + entity.HasIndex(e => e.TaskId); + entity.HasIndex(e => e.CreatedAt); + + entity.HasOne(e => e.Task) + .WithMany(t => t.Comments) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // KanbanTaskNote configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskNotes"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Title).IsRequired().HasMaxLength(100); + + entity.HasIndex(e => e.TaskId); + + entity.HasOne(e => e.Task) + .WithMany(t => t.Notes) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.LastEditedByUser) + .WithMany() + .HasForeignKey(e => e.LastEditedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // KanbanTaskAttachment configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskAttachments"); + entity.HasKey(e => e.Id); + entity.Property(e => e.FileName).IsRequired().HasMaxLength(255); + entity.Property(e => e.ContentType).IsRequired().HasMaxLength(100); + entity.Property(e => e.FilePath).HasMaxLength(500); + + // FileContent is nullable for migration to file-based storage + entity.Property(e => e.FileContent).IsRequired(false); + + entity.HasIndex(e => e.TaskId); + + entity.HasOne(e => e.Task) + .WithMany(t => t.Attachments) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.UploadedByUser) + .WithMany() + .HasForeignKey(e => e.UploadedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // KanbanTaskTemplate configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskTemplates"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.TitlePattern).HasMaxLength(200); + entity.Property(e => e.DefaultPriority).HasConversion(); + + entity.HasIndex(e => e.SortOrder); + + entity.HasOne(e => e.DefaultColumn) + .WithMany() + .HasForeignKey(e => e.DefaultColumnId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // KanbanTaskTemplateTodo configuration + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskTemplateTodos"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Text).IsRequired().HasMaxLength(500); + + entity.HasOne(e => e.Template) + .WithMany(t => t.Todos) + .HasForeignKey(e => e.TemplateId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // KanbanTaskLabel (many-to-many) + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskLabels"); + entity.HasKey(e => new { e.TaskId, e.LabelId }); + + entity.HasOne(e => e.Task) + .WithMany(t => t.TaskLabels) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Label) + .WithMany(l => l.TaskLabels) + .HasForeignKey(e => e.LabelId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // KanbanTaskTemplateLabel (many-to-many) + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskTemplateLabels"); + entity.HasKey(e => new { e.TemplateId, e.LabelId }); + + entity.HasOne(e => e.Template) + .WithMany(t => t.TemplateLabels) + .HasForeignKey(e => e.TemplateId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Label) + .WithMany(l => l.TemplateLabels) + .HasForeignKey(e => e.LabelId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // KanbanTaskAssignee (many-to-many) + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskAssignees"); + entity.HasKey(e => new { e.TaskId, e.UserId }); + + entity.HasOne(e => e.Task) + .WithMany(t => t.Assignees) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // KanbanTaskCustomer (many-to-many) + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskCustomers"); + entity.HasKey(e => new { e.TaskId, e.CustomerId }); + + entity.HasOne(e => e.Task) + .WithMany(t => t.TaskCustomers) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Customer) + .WithMany() + .HasForeignKey(e => e.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Vehicle) + .WithMany() + .HasForeignKey(e => e.VehicleId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // KanbanTaskTireStack (many-to-many) + modelBuilder.Entity(entity => + { + entity.ToTable("KanbanTaskTireStacks"); + entity.HasKey(e => new { e.TaskId, e.TireStackId }); + + entity.HasOne(e => e.Task) + .WithMany(t => t.TaskTireStacks) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.TireStack) + .WithMany() + .HasForeignKey(e => e.TireStackId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/Lagerbestand/Exceptions/StorageLocationExceptions.cs b/Lagerbestand/Exceptions/StorageLocationExceptions.cs new file mode 100644 index 0000000..6fba278 --- /dev/null +++ b/Lagerbestand/Exceptions/StorageLocationExceptions.cs @@ -0,0 +1,87 @@ +namespace Lagerbestand.Exceptions; + +/// +/// Base exception for storage location related errors. +/// +public class StorageLocationException : Exception +{ + public StorageLocationException(string message) : base(message) { } + public StorageLocationException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// Exception thrown when a location code is invalid or malformed. +/// +public class InvalidLocationCodeException : StorageLocationException +{ + public string? LocationCode { get; } + + public InvalidLocationCodeException(string locationCode) + : base($"Ungültiger Lagerplatz-Code: '{locationCode}'. Das Format muss ABCD sein (z.B. 1101).") + { + LocationCode = locationCode; + } + + public InvalidLocationCodeException(string locationCode, string reason) + : base($"Ungültiger Lagerplatz-Code '{locationCode}': {reason}") + { + LocationCode = locationCode; + } +} + +/// +/// Exception thrown when trying to use an occupied location. +/// +public class LocationOccupiedException : StorageLocationException +{ + public string LocationCode { get; } + public Guid? TireStackId { get; } + + public LocationOccupiedException(string locationCode) + : base($"Der Lagerplatz '{locationCode}' ist bereits belegt.") + { + LocationCode = locationCode; + } + + public LocationOccupiedException(string locationCode, Guid tireStackId) + : base($"Der Lagerplatz '{locationCode}' ist bereits mit Reifenstapel belegt.") + { + LocationCode = locationCode; + TireStackId = tireStackId; + } +} + +/// +/// Exception thrown when a location is not found. +/// +public class LocationNotFoundException : StorageLocationException +{ + public string? LocationCode { get; } + public Guid? LocationId { get; } + + public LocationNotFoundException(string locationCode) + : base($"Lagerplatz '{locationCode}' wurde nicht gefunden.") + { + LocationCode = locationCode; + } + + public LocationNotFoundException(Guid locationId) + : base($"Lagerplatz mit ID '{locationId}' wurde nicht gefunden.") + { + LocationId = locationId; + } +} + +/// +/// Exception thrown when a location code already exists. +/// +public class LocationCodeExistsException : StorageLocationException +{ + public string LocationCode { get; } + + public LocationCodeExistsException(string locationCode) + : base($"Der Lagerplatz-Code '{locationCode}' existiert bereits.") + { + LocationCode = locationCode; + } +} diff --git a/Lagerbestand/Lagerbestand.csproj b/Lagerbestand/Lagerbestand.csproj new file mode 100644 index 0000000..dfda79c --- /dev/null +++ b/Lagerbestand/Lagerbestand.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/Lagerbestand/Models/AppUser.cs b/Lagerbestand/Models/AppUser.cs new file mode 100644 index 0000000..be3f506 --- /dev/null +++ b/Lagerbestand/Models/AppUser.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +public enum UserRole +{ + User = 0, + Worker = 1, // Changed from Owner - limited access to tire operations and inventory + Admin = 2 // Full access to everything +} + +public class AppUser +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(100)] + public string Username { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string PasswordHash { get; set; } = string.Empty; + + [MaxLength(200)] + public string? DisplayName { get; set; } + + public UserRole Role { get; set; } = UserRole.User; + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? LastLoginAt { get; set; } + + /// + /// Whether 2FA is enabled for this user (user opted in or admin enforced). + /// + public bool TwoFactorEnabled { get; set; } + + /// + /// Base32-encoded TOTP secret key for authenticator apps. + /// + [MaxLength(256)] + public string? TwoFactorSecret { get; set; } + + /// + /// Whether an admin has enforced 2FA for this user. + /// When true, the user cannot disable 2FA themselves. + /// + public bool TwoFactorEnforced { get; set; } +} diff --git a/Lagerbestand/Models/AreaType.cs b/Lagerbestand/Models/AreaType.cs new file mode 100644 index 0000000..ce248cc --- /dev/null +++ b/Lagerbestand/Models/AreaType.cs @@ -0,0 +1,88 @@ +namespace Lagerbestand.Models; + +/// +/// Represents the storage area type for tire storage locations. +/// The value corresponds to position A in the location code (ABCD format). +/// +public enum AreaType +{ + /// + /// Werkstatt (Gebäude 1, 3 Regalreihen) - Code prefix: 1 + /// + Werkstatt = 1, + + /// + /// Lager (Gebäude 2, 5 Regalreihen) - Code prefix: 2 + /// + Lager = 2 +} + +/// +/// Extension methods for AreaType enum. +/// +public static class AreaTypeExtensions +{ + /// + /// Gets the German display name for the area type. + /// + public static string GetDisplayName(this AreaType areaType) => areaType switch + { + AreaType.Lager => "Lager", + AreaType.Werkstatt => "Werkstatt", + _ => areaType.ToString() + }; + + /// + /// Gets the single-digit code for the area type. + /// + public static string GetCode(this AreaType areaType) => ((int)areaType).ToString(); + + /// + /// Gets the maximum shelf row number for the building. + /// Werkstatt (Gebäude 1) has 3 rows, Lager (Gebäude 2) has 5 rows. + /// + public static int GetMaxRow(this AreaType areaType) => areaType switch + { + AreaType.Werkstatt => 3, + AreaType.Lager => 5, + _ => 1 + }; + + /// + /// Gets a display label including the building number (e.g., "Gebäude 1 – Werkstatt"). + /// + public static string GetBuildingLabel(this AreaType areaType) => + $"Gebäude {(int)areaType} – {areaType.GetDisplayName()}"; + + /// + /// Parses a code digit to AreaType. + /// + /// Single digit code (1 or 2) + /// The corresponding AreaType + /// If code is not valid + public static AreaType FromCode(char code) => code switch + { + '1' => AreaType.Werkstatt, + '2' => AreaType.Lager, + _ => throw new ArgumentException($"Ungültiger Bereichscode: {code}. Erlaubt: 1 (Werkstatt), 2 (Lager)", nameof(code)) + }; + + /// + /// Tries to parse a code digit to AreaType. + /// + public static bool TryFromCode(char code, out AreaType areaType) + { + areaType = default; + if (code == '1') + { + areaType = AreaType.Werkstatt; + return true; + } + if (code == '2') + { + areaType = AreaType.Lager; + return true; + } + return false; + } +} diff --git a/Lagerbestand/Models/CatalogItem.cs b/Lagerbestand/Models/CatalogItem.cs new file mode 100644 index 0000000..a76e48c --- /dev/null +++ b/Lagerbestand/Models/CatalogItem.cs @@ -0,0 +1,71 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +/// +/// Type of catalog item +/// +public enum CatalogItemType +{ + /// + /// Tire category + /// + Reifen = 0, + + /// + /// Rim/wheel category + /// + Felgen = 1 +} + +public class CatalogItem +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? PictureFileName { get; set; } + + /// + /// Size info - tire size for Reifen (e.g., "225/45 R17"), rim size for Felgen (e.g., "17 Zoll") + /// + [MaxLength(50)] + public string TireSize { get; set; } = string.Empty; + + public TireSeason Season { get; set; } = TireSeason.Sommer; + + /// + /// Category type (Reifen or Felgen) + /// + public CatalogItemType Type { get; set; } = CatalogItemType.Reifen; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + /// + /// Display label for the size field based on type + /// + [NotMapped] + public string SizeLabel => Type == CatalogItemType.Felgen ? "Felgengröße" : "Reifengröße"; + + /// + /// Display details string + /// + [NotMapped] + public string DisplayDetails => Type == CatalogItemType.Felgen + ? TireSize + : $"{TireSize} | {Season}"; +} + +public enum TireSeason +{ + Sommer = 0, + Winter = 1, + Allrad = 2 +} diff --git a/Lagerbestand/Models/Customer.cs b/Lagerbestand/Models/Customer.cs new file mode 100644 index 0000000..5ff62cc --- /dev/null +++ b/Lagerbestand/Models/Customer.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +/// +/// Represents a customer who stores tires at the shop. +/// A customer can have multiple vehicles. +/// +public class Customer +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(100)] + public string? FirstName { get; set; } + + [Required] + [MaxLength(100)] + public string LastName { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Company { get; set; } + + [MaxLength(20)] + public string? Phone { get; set; } + + [MaxLength(100)] + public string? Email { get; set; } + + [MaxLength(500)] + public string? Notes { get; set; } + + /// + /// Customer number for easy identification (e.g., K-0001) + /// + [MaxLength(20)] + public string CustomerNumber { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + /// + /// Navigation property for customer's vehicles + /// + public ICollection Vehicles { get; set; } = new List(); + + /// + /// Navigation property for customer's stored tire stacks + /// + public ICollection TireStacks { get; set; } = new List(); + + /// + /// Full name of the customer + /// + [NotMapped] + public string FullName => $"{FirstName} {LastName}".Trim(); + + /// + /// Display name including company if available + /// + [NotMapped] + public string DisplayName => string.IsNullOrEmpty(Company) + ? FullName + : $"{FullName} ({Company})"; + + /// + /// Gets the primary vehicle or the first vehicle if no primary is set + /// + [NotMapped] + public Vehicle? PrimaryVehicle => Vehicles + .Where(v => v.IsActive) + .OrderByDescending(v => v.IsPrimary) + .ThenBy(v => v.CreatedAt) + .FirstOrDefault(); + + /// + /// Gets all active vehicles for this customer + /// + [NotMapped] + public IEnumerable ActiveVehicles => Vehicles.Where(v => v.IsActive); + + } diff --git a/Lagerbestand/Models/FileStorageSettings.cs b/Lagerbestand/Models/FileStorageSettings.cs new file mode 100644 index 0000000..6e74315 --- /dev/null +++ b/Lagerbestand/Models/FileStorageSettings.cs @@ -0,0 +1,48 @@ +namespace Lagerbestand.Models; + +/// +/// File storage settings for NFS/disk-based file storage +/// +public class FileStorageSettings +{ + /// + /// Base path for file storage (e.g., /mnt/nfs/attachments or C:\Storage\Attachments) + /// + public string BasePath { get; set; } = string.Empty; + + /// + /// Whether file storage is configured and enabled + /// + public bool IsEnabled { get; set; } + + /// + /// Maximum file size in bytes (default: 10 MB) + /// + public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; + + /// + /// Validates if the storage path is accessible + /// + public bool IsPathAccessible() + { + if (string.IsNullOrWhiteSpace(BasePath)) + return false; + + try + { + return Directory.Exists(BasePath); + } + catch + { + return false; + } + } + + /// + /// Gets the full path for a given relative file path + /// + public string GetFullPath(string relativePath) + { + return Path.Combine(BasePath, relativePath); + } +} diff --git a/Lagerbestand/Models/InventoryItem.cs b/Lagerbestand/Models/InventoryItem.cs new file mode 100644 index 0000000..92c54f6 --- /dev/null +++ b/Lagerbestand/Models/InventoryItem.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +public class InventoryItem +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(500)] + public string QrCode { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Name { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + public Guid? CatalogItemId { get; set; } + + [ForeignKey(nameof(CatalogItemId))] + public CatalogItem? CatalogItem { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } +} diff --git a/Lagerbestand/Models/KanbanColumn.cs b/Lagerbestand/Models/KanbanColumn.cs new file mode 100644 index 0000000..75b7127 --- /dev/null +++ b/Lagerbestand/Models/KanbanColumn.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Represents a column in the Kanban board (e.g., Backlog, ToDo, In Progress) +/// +public class KanbanColumn +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + /// + /// Display order of the column (0-based) + /// + public int SortOrder { get; set; } + + /// + /// Optional color for the column header + /// + [MaxLength(7)] + public string? Color { get; set; } + + /// + /// Optional icon/emoji for the column + /// + [MaxLength(10)] + public string? Icon { get; set; } + + /// + /// Whether this is the default column for new tasks + /// + public bool IsDefault { get; set; } + + /// + /// Whether this column represents completed tasks (for archiving logic) + /// + public bool IsCompletedColumn { get; set; } + + /// + /// Whether this column is active + /// + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual ICollection Tasks { get; set; } = new List(); +} diff --git a/Lagerbestand/Models/KanbanLabel.cs b/Lagerbestand/Models/KanbanLabel.cs new file mode 100644 index 0000000..a8d4f66 --- /dev/null +++ b/Lagerbestand/Models/KanbanLabel.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Represents a label that can be attached to Kanban tasks +/// +public class KanbanLabel +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(50)] + public string Name { get; set; } = string.Empty; + + /// + /// Hex color code for the label (e.g., "#FF5733") + /// + [Required] + [MaxLength(7)] + public string Color { get; set; } = "#6B7280"; + + /// + /// Optional description for the label + /// + [MaxLength(200)] + public string? Description { get; set; } + + /// + /// Display order for labels + /// + public int SortOrder { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual ICollection TaskLabels { get; set; } = new List(); + public virtual ICollection TemplateLabels { get; set; } = new List(); +} + +/// +/// Predefined label colors for easy selection +/// +public static class KanbanLabelColors +{ + public static readonly Dictionary Presets = new() + { + { "Rot", "#EF4444" }, + { "Orange", "#F97316" }, + { "Gelb", "#EAB308" }, + { "Grün", "#22C55E" }, + { "Türkis", "#14B8A6" }, + { "Blau", "#3B82F6" }, + { "Indigo", "#6366F1" }, + { "Violett", "#8B5CF6" }, + { "Pink", "#EC4899" }, + { "Grau", "#6B7280" } + }; +} diff --git a/Lagerbestand/Models/KanbanStatistics.cs b/Lagerbestand/Models/KanbanStatistics.cs new file mode 100644 index 0000000..f315c0f --- /dev/null +++ b/Lagerbestand/Models/KanbanStatistics.cs @@ -0,0 +1,32 @@ +namespace Lagerbestand.Models; + +/// +/// Statistics for the Kanban dashboard +/// +public class KanbanStatistics +{ + /// + /// Total number of non-archived tasks + /// + public int TotalTasks { get; set; } + + /// + /// Number of tasks due today + /// + public int TasksDueToday { get; set; } + + /// + /// Number of overdue tasks + /// + public int OverdueTasks { get; set; } + + /// + /// Number of tasks completed this week + /// + public int CompletedThisWeek { get; set; } + + /// + /// Count of tasks per column + /// + public Dictionary TasksPerColumn { get; set; } = new(); +} diff --git a/Lagerbestand/Models/KanbanTask.cs b/Lagerbestand/Models/KanbanTask.cs new file mode 100644 index 0000000..813e4bd --- /dev/null +++ b/Lagerbestand/Models/KanbanTask.cs @@ -0,0 +1,141 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Priority levels for Kanban tasks +/// +public enum KanbanTaskPriority +{ + Low = 0, + Medium = 1, + High = 2, + Urgent = 3 +} + +/// +/// Represents a task/card in the Kanban board +/// +public class KanbanTask +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + /// + /// Rich text description of the task + /// + public string? Description { get; set; } + + /// + /// The column this task belongs to + /// + public Guid ColumnId { get; set; } + + /// + /// Sort order within the column + /// + public int SortOrder { get; set; } + + /// + /// Due date for the task + /// + public DateTime? DueDate { get; set; } + + /// + /// Priority level + /// + public KanbanTaskPriority Priority { get; set; } = KanbanTaskPriority.Medium; + + /// + /// Estimated time in minutes + /// + public int? EstimatedMinutes { get; set; } + + /// + /// User who created the task + /// + public Guid? CreatedByUserId { get; set; } + + /// + /// Whether the task is archived + /// + public bool IsArchived { get; set; } + + /// + /// When the task was moved to a completed column + /// + public DateTime? CompletedAt { get; set; } + + /// + /// When the task was archived + /// + public DateTime? ArchivedAt { get; set; } + + /// + /// Simple notes/memo field for the task + /// + public string? NotesText { get; set; } + + /// + /// Free text field for tire size notes (Reifengröße) + /// + public string? TireSizeNote { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual KanbanColumn? Column { get; set; } + public virtual AppUser? CreatedByUser { get; set; } + + public virtual ICollection Todos { get; set; } = new List(); + public virtual ICollection TaskLabels { get; set; } = new List(); + public virtual ICollection Comments { get; set; } = new List(); + public virtual ICollection Notes { get; set; } = new List(); + public virtual ICollection Attachments { get; set; } = new List(); + public virtual ICollection Assignees { get; set; } = new List(); + public virtual ICollection TaskCustomers { get; set; } = new List(); + public virtual ICollection TaskTireStacks { get; set; } = new List(); + + // Computed properties + public bool IsDueToday => DueDate.HasValue && DueDate.Value.Date == DateTime.UtcNow.Date && !CompletedAt.HasValue; + public bool IsOverdue => DueDate.HasValue && DueDate.Value.Date < DateTime.UtcNow.Date && !IsArchived && !CompletedAt.HasValue; + + public int TodosCompleted => Todos.Count(t => t.IsCompleted); + public int TodosTotal => Todos.Count; + public string TodoProgress => TodosTotal > 0 ? $"{TodosCompleted}/{TodosTotal}" : ""; + + public string PriorityText => Priority switch + { + KanbanTaskPriority.Low => "Niedrig", + KanbanTaskPriority.Medium => "Mittel", + KanbanTaskPriority.High => "Hoch", + KanbanTaskPriority.Urgent => "Dringend", + _ => "Mittel" + }; + + public string PriorityIcon => Priority switch + { + KanbanTaskPriority.Low => "⬇ï¸�", + KanbanTaskPriority.Medium => "âž¡ï¸�", + KanbanTaskPriority.High => "⬆ï¸�", + KanbanTaskPriority.Urgent => "🔥", + _ => "âž¡ï¸�" + }; + + public string EstimatedTimeDisplay + { + get + { + if (!EstimatedMinutes.HasValue) return ""; + var hours = EstimatedMinutes.Value / 60; + var mins = EstimatedMinutes.Value % 60; + if (hours > 0 && mins > 0) return $"{hours}h {mins}m"; + if (hours > 0) return $"{hours}h"; + return $"{mins}m"; + } + } +} diff --git a/Lagerbestand/Models/KanbanTaskAttachment.cs b/Lagerbestand/Models/KanbanTaskAttachment.cs new file mode 100644 index 0000000..2b80ceb --- /dev/null +++ b/Lagerbestand/Models/KanbanTaskAttachment.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +/// +/// Represents a file attachment on a Kanban task +/// +public class KanbanTaskAttachment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TaskId { get; set; } + + [Required] + [MaxLength(255)] + public string FileName { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string ContentType { get; set; } = "application/octet-stream"; + + /// + /// File size in bytes + /// + public long FileSize { get; set; } + + /// + /// Relative path to the file in the storage system (NFS/disk) + /// + [MaxLength(500)] + public string? FilePath { get; set; } + + /// + /// Legacy: File content stored as binary (being migrated to file-based storage) + /// + [Obsolete("Use FilePath instead. This property is retained for migration purposes only.")] + public byte[]? FileContent { get; set; } + + /// + /// User who uploaded the file + /// + public Guid? UploadedByUserId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual AppUser? UploadedByUser { get; set; } + + // Computed properties + [NotMapped] + public string FileSizeDisplay + { + get + { + if (FileSize < 1024) return $"{FileSize} B"; + if (FileSize < 1024 * 1024) return $"{FileSize / 1024.0:F1} KB"; + return $"{FileSize / (1024.0 * 1024.0):F1} MB"; + } + } + + [NotMapped] + public string FileIcon + { + get + { + var ext = Path.GetExtension(FileName).ToLowerInvariant(); + return ext switch + { + ".pdf" => "📄", + ".doc" or ".docx" => "ðŸ“�", + ".xls" or ".xlsx" => "📊", + ".ppt" or ".pptx" => "📽ï¸�", + ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" => "🖼ï¸�", + ".zip" or ".rar" or ".7z" => "📦", + ".txt" => "📃", + _ => "📎" + }; + } + } + + [NotMapped] + public bool IsImage => ContentType.StartsWith("image/"); + + /// + /// Whether the file is stored on disk (vs legacy database storage) + /// + [NotMapped] + public bool IsFileBasedStorage => !string.IsNullOrEmpty(FilePath); + + /// + /// Maximum file size allowed (10 MB) + /// + public const long MaxFileSize = 10 * 1024 * 1024; +} diff --git a/Lagerbestand/Models/KanbanTaskComment.cs b/Lagerbestand/Models/KanbanTaskComment.cs new file mode 100644 index 0000000..74e55ef --- /dev/null +++ b/Lagerbestand/Models/KanbanTaskComment.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Type of comment (chat message or system activity) +/// +public enum KanbanCommentType +{ + Chat = 0, + Activity = 1 +} + +/// +/// Represents a chat message or activity log entry on a Kanban task +/// +public class KanbanTaskComment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TaskId { get; set; } + + /// + /// User who created the comment (null for system activities) + /// + public Guid? UserId { get; set; } + + [Required] + public string Content { get; set; } = string.Empty; + + /// + /// Whether this is a chat message or system activity + /// + public KanbanCommentType CommentType { get; set; } = KanbanCommentType.Chat; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual AppUser? User { get; set; } + + // Computed properties + public bool IsActivity => CommentType == KanbanCommentType.Activity; + public bool IsChat => CommentType == KanbanCommentType.Chat; +} diff --git a/Lagerbestand/Models/KanbanTaskNote.cs b/Lagerbestand/Models/KanbanTaskNote.cs new file mode 100644 index 0000000..7da1bce --- /dev/null +++ b/Lagerbestand/Models/KanbanTaskNote.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Represents a shared editable note on a Kanban task (like MS Teams wiki) +/// +public class KanbanTaskNote +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TaskId { get; set; } + + [Required] + [MaxLength(100)] + public string Title { get; set; } = "Notizen"; + + /// + /// Rich text content of the note + /// + public string Content { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + /// + /// Last user who edited the note + /// + public Guid? LastEditedByUserId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual AppUser? LastEditedByUser { get; set; } +} diff --git a/Lagerbestand/Models/KanbanTaskRelations.cs b/Lagerbestand/Models/KanbanTaskRelations.cs new file mode 100644 index 0000000..4d496af --- /dev/null +++ b/Lagerbestand/Models/KanbanTaskRelations.cs @@ -0,0 +1,57 @@ +namespace Lagerbestand.Models; + +/// +/// Many-to-many relationship between KanbanTask and KanbanLabel +/// +public class KanbanTaskLabel +{ + public Guid TaskId { get; set; } + public Guid LabelId { get; set; } + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual KanbanLabel? Label { get; set; } +} + +/// +/// Many-to-many relationship between KanbanTask and User (assignees) +/// +public class KanbanTaskAssignee +{ + public Guid TaskId { get; set; } + public Guid UserId { get; set; } + + public DateTime AssignedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual AppUser? User { get; set; } +} + +/// +/// Many-to-many relationship between KanbanTask and Customer +/// +public class KanbanTaskCustomer +{ + public Guid TaskId { get; set; } + public Guid CustomerId { get; set; } + public Guid? VehicleId { get; set; } + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual Customer? Customer { get; set; } + public virtual Vehicle? Vehicle { get; set; } +} + +/// +/// Many-to-many relationship between KanbanTask and TireStack +/// +public class KanbanTaskTireStack +{ + public Guid TaskId { get; set; } + public Guid TireStackId { get; set; } + + // Navigation properties + public virtual KanbanTask? Task { get; set; } + public virtual TireStack? TireStack { get; set; } +} diff --git a/Lagerbestand/Models/KanbanTaskTemplate.cs b/Lagerbestand/Models/KanbanTaskTemplate.cs new file mode 100644 index 0000000..81aa349 --- /dev/null +++ b/Lagerbestand/Models/KanbanTaskTemplate.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Represents a task template that can be used to quickly create new tasks +/// +public class KanbanTaskTemplate +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + /// + /// Title pattern for tasks created from this template + /// Can include placeholders like {Kunde} + /// + [MaxLength(200)] + public string TitlePattern { get; set; } = string.Empty; + + /// + /// Default description for tasks + /// + public string? DefaultDescription { get; set; } + + /// + /// Default column for tasks created from this template + /// + public Guid? DefaultColumnId { get; set; } + + /// + /// Default priority for tasks + /// + public KanbanTaskPriority DefaultPriority { get; set; } = KanbanTaskPriority.Medium; + + /// + /// Default estimated minutes + /// + public int? DefaultEstimatedMinutes { get; set; } + + /// + /// Display order for templates + /// + public int SortOrder { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public virtual KanbanColumn? DefaultColumn { get; set; } + public virtual ICollection Todos { get; set; } = new List(); + public virtual ICollection TemplateLabels { get; set; } = new List(); +} + +/// +/// Predefined todo item for a template +/// +public class KanbanTaskTemplateTodo +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TemplateId { get; set; } + + [Required] + [MaxLength(500)] + public string Text { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + // Navigation properties + public virtual KanbanTaskTemplate? Template { get; set; } +} + +/// +/// Default label for a template (many-to-many) +/// +public class KanbanTaskTemplateLabel +{ + public Guid TemplateId { get; set; } + public Guid LabelId { get; set; } + + // Navigation properties + public virtual KanbanTaskTemplate? Template { get; set; } + public virtual KanbanLabel? Label { get; set; } +} diff --git a/Lagerbestand/Models/KanbanTaskTodo.cs b/Lagerbestand/Models/KanbanTaskTodo.cs new file mode 100644 index 0000000..6f4f916 --- /dev/null +++ b/Lagerbestand/Models/KanbanTaskTodo.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lagerbestand.Models; + +/// +/// Represents a todo/checklist item within a Kanban task +/// +public class KanbanTaskTodo +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TaskId { get; set; } + + [Required] + [MaxLength(500)] + public string Text { get; set; } = string.Empty; + + public bool IsCompleted { get; set; } + + public int SortOrder { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + + // Navigation properties + public virtual KanbanTask? Task { get; set; } +} diff --git a/Lagerbestand/Models/ShelfPosition.cs b/Lagerbestand/Models/ShelfPosition.cs new file mode 100644 index 0000000..a33354b --- /dev/null +++ b/Lagerbestand/Models/ShelfPosition.cs @@ -0,0 +1,113 @@ +namespace Lagerbestand.Models; + +/// +/// Represents the position within a shelf slot (side + level). +/// The value corresponds to position C in the location code (ABCD format). +/// +public enum ShelfPosition +{ + /// + /// Links oben - Code: 1 + /// + LinksOben = 1, + + /// + /// Links unten - Code: 2 + /// + LinksUnten = 2, + + /// + /// Rechts oben - Code: 3 + /// + RechtsOben = 3, + + /// + /// Rechts unten - Code: 4 + /// + RechtsUnten = 4 +} + +/// +/// Extension methods for ShelfPosition enum. +/// +public static class ShelfPositionExtensions +{ + /// + /// Gets the German display name for the shelf position. + /// + public static string GetDisplayName(this ShelfPosition position) => position switch + { + ShelfPosition.LinksOben => "Links oben", + ShelfPosition.LinksUnten => "Links unten", + ShelfPosition.RechtsOben => "Rechts oben", + ShelfPosition.RechtsUnten => "Rechts unten", + _ => position.ToString() + }; + + /// + /// Gets a short display name (e.g., "L↑") for compact grid views. + /// + public static string GetShortName(this ShelfPosition position) => position switch + { + ShelfPosition.LinksOben => "L↑", + ShelfPosition.LinksUnten => "L↓", + ShelfPosition.RechtsOben => "R↑", + ShelfPosition.RechtsUnten => "R↓", + _ => position.ToString() + }; + + /// + /// Whether the position is on the left side of the shelf. + /// + public static bool IsLeft(this ShelfPosition position) => + position is ShelfPosition.LinksOben or ShelfPosition.LinksUnten; + + /// + /// Whether the position is on the top level of the shelf. + /// + public static bool IsTop(this ShelfPosition position) => + position is ShelfPosition.LinksOben or ShelfPosition.RechtsOben; + + /// + /// Gets the single-digit code for the position. + /// + public static string GetCode(this ShelfPosition position) => ((int)position).ToString(); + + /// + /// Parses a code digit to ShelfPosition. + /// + /// Single digit code (1-4) + /// If code is not valid + public static ShelfPosition FromCode(char code) => code switch + { + '1' => ShelfPosition.LinksOben, + '2' => ShelfPosition.LinksUnten, + '3' => ShelfPosition.RechtsOben, + '4' => ShelfPosition.RechtsUnten, + _ => throw new ArgumentException($"Ungültiger Positionscode: {code}. Erlaubt: 1-4", nameof(code)) + }; + + /// + /// Tries to parse a code digit to ShelfPosition. + /// + public static bool TryFromCode(char code, out ShelfPosition position) + { + position = default; + if (code is < '1' or > '4') + return false; + + position = (ShelfPosition)(code - '0'); + return true; + } + + /// + /// All shelf positions in display order. + /// + public static readonly ShelfPosition[] All = + [ + ShelfPosition.LinksOben, + ShelfPosition.LinksUnten, + ShelfPosition.RechtsOben, + ShelfPosition.RechtsUnten + ]; +} diff --git a/Lagerbestand/Models/SiteSettings.cs b/Lagerbestand/Models/SiteSettings.cs new file mode 100644 index 0000000..4653f7c --- /dev/null +++ b/Lagerbestand/Models/SiteSettings.cs @@ -0,0 +1,189 @@ +namespace Lagerbestand.Models; + +/// +/// Site-wide settings stored in the database +/// +public class SiteSettings +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Unique key for the setting (e.g., "PromoBanner") + /// + public string Key { get; set; } = string.Empty; + + /// + /// JSON-serialized value of the setting + /// + public string Value { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } +} + +/// +/// Promotion banner settings +/// +public class PromoBannerSettings +{ + public bool IsEnabled { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public bool ShowContactButton { get; set; } = true; + + /// + /// Check if banner should be shown based on current date and content + /// + public bool ShouldShow() + { + // Must be enabled + if (!IsEnabled) return false; + + // Must have content + if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(Message)) + return false; + + var now = DateTime.UtcNow.Date; + + // Check date range if specified + if (StartDate.HasValue && now < StartDate.Value.Date) + return false; + + if (EndDate.HasValue && now > EndDate.Value.Date) + return false; + + return true; + } +} + +/// +/// Lexware API connection settings for customer data synchronization +/// +public class LexwareApiSettings +{ + public bool IsEnabled { get; set; } + + /// + /// Lexware API base URL (e.g., https://api.lexoffice.io/v1) + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// API key for authentication + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Whether automatic daily sync is enabled + /// + public bool AutoSyncEnabled { get; set; } = true; + + /// + /// Time of day for automatic sync (HH:mm format) + /// + public string AutoSyncTime { get; set; } = "02:00"; + + /// + /// Timestamp of the last successful sync + /// + public DateTime? LastSyncAt { get; set; } + + /// + /// Result message from the last sync + /// + public string? LastSyncResult { get; set; } + + /// + /// Number of customers synced in last run + /// + public int LastSyncCount { get; set; } + + /// + /// Whether the API connection is configured + /// + public bool IsConfigured => !string.IsNullOrWhiteSpace(BaseUrl) && !string.IsNullOrWhiteSpace(ApiKey); +} + +/// +/// Two-Factor Authentication settings stored in SiteSettings +/// +public class TwoFactorSettings +{ + /// + /// Global toggle: when false, no 2FA is used at all + /// + public bool IsEnabled { get; set; } + + /// + /// Issuer name shown in authenticator apps (e.g. "Reifen Späth") + /// + public string Issuer { get; set; } = "Reifen Späth Lagerverwaltung"; + + /// + /// Whether email fallback codes are enabled + /// + public bool EmailFallbackEnabled { get; set; } +} + +/// +/// Session / long-duration usage settings (idle lock, circuit retention). +/// Editable by admins in the Status page. +/// +public class SessionSettings +{ + public const int DefaultIdleTimeoutMinutes = 60; + public const int MaxIdleTimeoutMinutes = 60; + public const int MinIdleTimeoutMinutes = 1; + + public const double DefaultCircuitRetentionHours = 2.0; + public const double MaxCircuitRetentionHours = 4.0; + public const double MinCircuitRetentionHours = 0.5; + + /// + /// Minutes of inactivity before the browser tab shows a "Confirm to continue" lock overlay. + /// Range: 1 – 60. Default: 60. + /// + public int IdleTimeoutMinutes { get; set; } = DefaultIdleTimeoutMinutes; + + /// + /// Hours a disconnected Blazor Server circuit is retained on the server + /// before being discarded (allows reconnect without state loss). + /// Range: 0.5 – 4. Default: 2. Applied at next app restart. + /// + public double CircuitRetentionHours { get; set; } = DefaultCircuitRetentionHours; + + public int GetSafeIdleTimeoutMinutes() + { + if (IdleTimeoutMinutes < MinIdleTimeoutMinutes) return MinIdleTimeoutMinutes; + if (IdleTimeoutMinutes > MaxIdleTimeoutMinutes) return MaxIdleTimeoutMinutes; + return IdleTimeoutMinutes; + } + + public double GetSafeCircuitRetentionHours() + { + if (CircuitRetentionHours < MinCircuitRetentionHours) return MinCircuitRetentionHours; + if (CircuitRetentionHours > MaxCircuitRetentionHours) return MaxCircuitRetentionHours; + return CircuitRetentionHours; + } +} + +/// +/// SMTP settings for sending email-based 2FA codes and notifications +/// +public class SmtpSettings +{ + public bool IsEnabled { get; set; } + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 587; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; + public string FromName { get; set; } = "Reifen Späth Lagerverwaltung"; + public bool UseSsl { get; set; } = true; + + public bool IsConfigured => !string.IsNullOrWhiteSpace(Host) + && !string.IsNullOrWhiteSpace(Username) + && !string.IsNullOrWhiteSpace(FromEmail); +} diff --git a/Lagerbestand/Models/StorageLocation.cs b/Lagerbestand/Models/StorageLocation.cs new file mode 100644 index 0000000..af04434 --- /dev/null +++ b/Lagerbestand/Models/StorageLocation.cs @@ -0,0 +1,252 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.RegularExpressions; + +namespace Lagerbestand.Models; + +/// +/// Represents a physical storage location in the warehouse. +/// Storage locations are identified by a 4-digit code in ABCD format: +/// - A: Gebäude (1=Werkstatt mit 3 Reihen, 2=Lager mit 5 Reihen) +/// - B: Regalreihe (Werkstatt: 1-3, Lager: 1-5) +/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten) +/// - D: Platz längs (1-9) +/// Example: 2235 = Lager, Reihe 2, rechts oben, Platz 5 +/// +public class StorageLocation +{ + /// + /// Regex pattern for valid location codes: ABCD format + /// A = 1-2 (Gebäude), B = 1-5 (Reihe), C = 1-4 (Seite/Ebene), D = 1-9 (Platz) + /// + public const string LocationCodePattern = @"^[12][1-5][1-4][1-9]$"; + + /// + /// Minimum row number + /// + public const int MinRow = 1; + + /// + /// Maximum row number (Lager, Gebäude 2) + /// + public const int MaxRow = 5; + + /// + /// Maximum row number for Werkstatt (Gebäude 1) + /// + public const int MaxRowWerkstatt = 3; + + /// + /// Maximum row number for Lager (Gebäude 2) + /// + public const int MaxRowLager = 5; + + /// + /// Minimum slot number (längs) + /// + public const int MinSlot = 1; + + /// + /// Maximum slot number (längs) + /// + public const int MaxSlot = 9; + + /// + /// Number of positions per lengthwise slot (links/rechts × oben/unten) + /// + public const int PositionsPerSlot = 4; + + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Unique 4-digit location code in ABCD format (e.g., "2235"). + /// This is the primary human-readable identifier. + /// + [Required] + [MaxLength(4)] + [RegularExpression(LocationCodePattern, ErrorMessage = "Ungültiges Code-Format. Erwartet: ABCD (z.B. 2235)")] + public string Code { get; set; } = string.Empty; + + /// + /// Storage building (Werkstatt=1, Lager=2). + /// Corresponds to position A in the location code. + /// + [Required] + public AreaType Area { get; set; } = AreaType.Lager; + + /// + /// Shelf row number within the building (Werkstatt: 1-3, Lager: 1-5). + /// Corresponds to position B in the location code. + /// + [Required] + [Range(MinRow, MaxRow, ErrorMessage = "Reihe muss zwischen 1 und 5 liegen.")] + public int Row { get; set; } = 1; + + /// + /// Position within the shelf slot: side (links/rechts) and level (oben/unten). + /// Corresponds to position C in the location code. + /// + [Required] + public ShelfPosition Position { get; set; } = ShelfPosition.LinksOben; + + /// + /// Lengthwise slot number within the row (1-9). + /// Corresponds to position D in the location code. + /// + [Required] + [Range(MinSlot, MaxSlot, ErrorMessage = "Platz muss zwischen 1 und 9 liegen.")] + public int SlotNumber { get; set; } = 1; + + /// + /// Whether this location is currently occupied by a tire stack. + /// + public bool IsOccupied { get; set; } + + /// + /// The ID of the tire stack currently stored at this location (if occupied). + /// + public Guid? TireStackId { get; set; } + + /// + /// Navigation property to the tire stack at this location. + /// + [ForeignKey(nameof(TireStackId))] + public TireStack? OccupiedTireStack { get; set; } + + /// + /// When the current tire stack was placed at this location. + /// + public DateTime? OccupiedSince { get; set; } + + /// + /// Optional notes about this location. + /// + [MaxLength(500)] + public string? Notes { get; set; } + + /// + /// Whether this location is available for new storage (not blocked). + /// + public bool IsAvailable { get; set; } = true; + + /// + /// Whether this location is active in the system. + /// + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + /// + /// Navigation property for tire stacks stored at this location (legacy support). + /// + public ICollection TireStacks { get; set; } = new List(); + + /// + /// Display name showing the human-readable location. + /// + [NotMapped] + public string DisplayName => $"{Area.GetDisplayName()}, Reihe {Row}, {Position.GetDisplayName()}, Platz {SlotNumber}"; + + /// + /// Short display for compact views. + /// + [NotMapped] + public string ShortDisplay => $"{Area.GetDisplayName()} R{Row} {Position.GetShortName()} P{SlotNumber}"; + + /// + /// Full path description of the location. + /// + [NotMapped] + public string FullPath => $"{Area.GetBuildingLabel()} / Reihe {Row} / {Position.GetDisplayName()} / Platz {SlotNumber}"; + + /// + /// Current occupancy count (number of stacks stored). + /// + [NotMapped] + public int CurrentOccupancy => IsOccupied ? 1 : 0; + + /// + /// Capacity is always 1 for the new system (one stack per location). + /// + [NotMapped] + public int Capacity => 1; + + /// + /// Generates the location code from the component parts. + /// + public void GenerateCode() + { + Code = $"{(int)Area}{Row}{(int)Position}{SlotNumber}"; + } + + /// + /// Parses the location code and populates the Area, Row, Position, and SlotNumber properties. + /// + /// True if parsing was successful + public bool ParseCode() + { + if (string.IsNullOrWhiteSpace(Code) || Code.Length != 4) + return false; + + if (!Regex.IsMatch(Code, LocationCodePattern)) + return false; + + if (!AreaTypeExtensions.TryFromCode(Code[0], out var area)) + return false; + + if (!int.TryParse(Code[1].ToString(), out var row) || row < MinRow || row > area.GetMaxRow()) + return false; + + if (!ShelfPositionExtensions.TryFromCode(Code[2], out var position)) + return false; + + if (!int.TryParse(Code[3].ToString(), out var slot) || slot < MinSlot || slot > MaxSlot) + return false; + + Area = area; + Row = row; + Position = position; + SlotNumber = slot; + return true; + } + + /// + /// Validates that the current Code matches the Area, Row, Position, and SlotNumber values. + /// + public bool ValidateCodeConsistency() + { + var expectedCode = $"{(int)Area}{Row}{(int)Position}{SlotNumber}"; + return Code == expectedCode; + } + + /// + /// Assigns a tire stack to this location. + /// + /// The ID of the tire stack to assign + /// If location is already occupied + public void AssignTireStack(Guid tireStackId) + { + if (IsOccupied) + throw new InvalidOperationException($"Lagerplatz {Code} ist bereits belegt."); + + TireStackId = tireStackId; + IsOccupied = true; + OccupiedSince = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Clears the tire stack from this location. + /// + public void ClearLocation() + { + TireStackId = null; + OccupiedTireStack = null; + IsOccupied = false; + OccupiedSince = null; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/Lagerbestand/Models/TireItem.cs b/Lagerbestand/Models/TireItem.cs new file mode 100644 index 0000000..0ec4388 --- /dev/null +++ b/Lagerbestand/Models/TireItem.cs @@ -0,0 +1,111 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +/// +/// Tire position in a stack +/// +public enum TirePosition +{ + FrontLeft = 0, + FrontRight = 1, + RearLeft = 2, + RearRight = 3 +} + +/// +/// Tire condition rating +/// +public enum TireCondition +{ + Excellent = 0, // Like new + Good = 1, // Normal wear + Fair = 2, // Noticeable wear but safe + Poor = 3, // Should be replaced soon + Critical = 4 // Unsafe, needs replacement +} + +/// +/// Represents an individual tire within a tire stack. +/// Allows detailed tracking of each tire's condition. +/// A TireStack always has exactly 4 TireItems. +/// +public class TireItem +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// The tire stack this item belongs to + /// + public Guid TireStackId { get; set; } + + [ForeignKey(nameof(TireStackId))] + public TireStack? TireStack { get; set; } + + /// + /// Position of this tire in the stack + /// + public TirePosition Position { get; set; } + + /// + /// Individual tread depth for this tire in mm + /// + [Range(0, 10)] + public decimal? TreadDepth { get; set; } + + /// + /// Condition rating for this tire + /// + public TireCondition Condition { get; set; } = TireCondition.Good; + + /// + /// Any damage or issues noted + /// + [MaxLength(500)] + public string? DamageNotes { get; set; } + + /// + /// Individual DOT code if different from stack + /// + [MaxLength(20)] + public string? DotCode { get; set; } + + /// + /// Photo filename for tire condition documentation + /// + [MaxLength(500)] + public string? PhotoFileName { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + /// + /// Position display text in German + /// + [NotMapped] + public string PositionText => Position switch + { + TirePosition.FrontLeft => "Vorne Links", + TirePosition.FrontRight => "Vorne Rechts", + TirePosition.RearLeft => "Hinten Links", + TirePosition.RearRight => "Hinten Rechts", + _ => "Unbekannt" + }; + + /// + /// Condition display text in German + /// + [NotMapped] + public string ConditionText => Condition switch + { + TireCondition.Excellent => "Sehr gut", + TireCondition.Good => "Gut", + TireCondition.Fair => "Ausreichend", + TireCondition.Poor => "Mangelhaft", + TireCondition.Critical => "Kritisch", + _ => "Unbekannt" + }; +} diff --git a/Lagerbestand/Models/TireStack.cs b/Lagerbestand/Models/TireStack.cs new file mode 100644 index 0000000..9b6cf0d --- /dev/null +++ b/Lagerbestand/Models/TireStack.cs @@ -0,0 +1,259 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +/// +/// Status of a tire stack in the storage system +/// +public enum TireStackStatus +{ + /// + /// Tires are currently stored in the warehouse + /// + Stored = 0, + + /// + /// Tires have been picked up by the customer + /// + PickedUp = 1, + + /// + /// Tires have been disposed/recycled + /// + Disposed = 2, + + /// + /// Tires are reserved for pickup + /// + Reserved = 3, + + /// + /// Tires are in inventory (shop stock, not customer-owned) + /// + Inventory = 4 +} + +/// +/// Represents a stack of exactly 4 tires as a single indivisible unit. +/// A stack is always 4 identical tires - there is no variable count. +/// This is the main entity that tracks customer tire storage and shop inventory. +/// +public class TireStack +{ + /// + /// Number of tires in a stack - always 4 + /// + public const int TiresPerStack = 4; + + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Custom item count for inventory (default: 4 = standard stack). + /// Only used for inventory items. Customer stacks always use TiresPerStack. + /// + [Range(1, 99)] + public int ItemCount { get; set; } = TiresPerStack; + + /// + /// Unique identifier/ticket number for this tire stack (e.g., "TS-2024-0001") + /// This serves as the primary human-readable identifier. + /// + [Required] + [MaxLength(50)] + public string TicketNumber { get; set; } = string.Empty; + + /// + /// Customer who owns these tires (null for shop inventory) + /// + public Guid? CustomerId { get; set; } + + [ForeignKey(nameof(CustomerId))] + public Customer? Customer { get; set; } + + /// + /// Vehicle these tires belong to (null for shop inventory or legacy data) + /// + public Guid? VehicleId { get; set; } + + [ForeignKey(nameof(VehicleId))] + public Vehicle? Vehicle { get; set; } + + /// + /// Current storage location (null if picked up) + /// + public Guid? StorageLocationId { get; set; } + + [ForeignKey(nameof(StorageLocationId))] + public StorageLocation? StorageLocation { get; set; } + + /// + /// Tire category/type (used for inventory categorization) + /// + public Guid? CatalogItemId { get; set; } + + [ForeignKey(nameof(CatalogItemId))] + public CatalogItem? CatalogItem { get; set; } + + /// + /// Current status of the tire stack + /// + public TireStackStatus Status { get; set; } = TireStackStatus.Stored; + + /// + /// Tire size (e.g., "225/45R17") + /// + [MaxLength(50)] + public string? TireSize { get; set; } + + /// + /// Tire brand (e.g., "Michelin", "Continental") + /// + [MaxLength(100)] + public string? TireBrand { get; set; } + + /// + /// Tire model (e.g., "Pilot Sport 4", "WinterContact TS 860") + /// + [MaxLength(100)] + public string? TireModel { get; set; } + + /// + /// Season type + /// + public TireSeason Season { get; set; } = TireSeason.Sommer; + + /// + /// Tread depth in mm (average of all 4 tires) + /// + [Range(0, 10)] + public decimal? TreadDepth { get; set; } + + /// + /// DOT/manufacturing date code (e.g., "2523" = week 25 of 2023) + /// + [MaxLength(20)] + public string? DotCode { get; set; } + + /// + /// Whether tires are mounted on rims + /// + public bool WithRims { get; set; } = true; + + /// + /// Rim size if with rims (e.g., "17 Zoll") + /// + [MaxLength(20)] + public string? RimSize { get; set; } + + /// + /// TPMS sensors present + /// + public bool HasTpms { get; set; } + + /// + /// Tire condition rating + /// + public TireCondition Condition { get; set; } = TireCondition.Good; + + /// + /// Additional notes about the tire condition or special requirements + /// + [MaxLength(1000)] + public string? Notes { get; set; } + + /// + /// Date when tires were stored + /// + public DateTime StoredAt { get; set; } = DateTime.UtcNow; + + /// + /// Date when tires were picked up + /// + public DateTime? PickedUpAt { get; set; } + + /// + /// Expected pickup date (next season change) + /// + public DateTime? ExpectedPickupDate { get; set; } + + /// + /// User who stored the tires + /// + public Guid? StoredByUserId { get; set; } + + /// + /// User who processed the pickup + /// + public Guid? PickedUpByUserId { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + /// + /// Whether this is shop inventory (not customer-owned) + /// + [NotMapped] + public bool IsInventory => CustomerId == null; + + /// + /// Display name - customer name or category name for inventory + /// + [NotMapped] + public string DisplayName => Customer?.FullName ?? CatalogItem?.Name ?? "Lagerbestand"; + + /// + /// Summary description for display + /// + [NotMapped] + public string Summary + { + get + { + var parts = new List(); + if (!string.IsNullOrEmpty(TireBrand)) parts.Add(TireBrand); + if (!string.IsNullOrEmpty(TireSize)) parts.Add(TireSize); + parts.Add(Season.ToString()); + if (WithRims) parts.Add("mit Felgen"); + return string.Join(" | ", parts); + } + } + + /// + /// Status display text in German + /// + [NotMapped] + public string StatusText => Status switch + { + TireStackStatus.Stored => "Eingelagert", + TireStackStatus.PickedUp => "Abgeholt", + TireStackStatus.Disposed => "Entsorgt", + TireStackStatus.Reserved => "Reserviert", + TireStackStatus.Inventory => "Lagerbestand", + _ => "Unbekannt" + }; + + /// + /// Days stored (for billing purposes) + /// + [NotMapped] + public int DaysStored => (PickedUpAt ?? DateTime.UtcNow).Subtract(StoredAt).Days; + + /// + /// Condition display text in German + /// + [NotMapped] + public string ConditionText => Condition switch + { + TireCondition.Excellent => "Sehr gut", + TireCondition.Good => "Gut", + TireCondition.Fair => "Ausreichend", + TireCondition.Poor => "Mangelhaft", + TireCondition.Critical => "Kritisch", + _ => "Unbekannt" + }; +} diff --git a/Lagerbestand/Models/Vehicle.cs b/Lagerbestand/Models/Vehicle.cs new file mode 100644 index 0000000..45af207 --- /dev/null +++ b/Lagerbestand/Models/Vehicle.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Lagerbestand.Models; + +/// +/// Represents a vehicle owned by a customer. +/// A customer can have multiple vehicles, each with their own tire stacks. +/// +public class Vehicle +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// The customer who owns this vehicle + /// + public Guid CustomerId { get; set; } + + [ForeignKey(nameof(CustomerId))] + public Customer? Customer { get; set; } + + /// + /// Vehicle manufacturer (e.g., "BMW", "Mercedes", "VW") + /// + [Required] + [MaxLength(100)] + public string Make { get; set; } = string.Empty; + + /// + /// Vehicle model (e.g., "3er", "C-Klasse", "Golf") + /// + [MaxLength(100)] + public string? Model { get; set; } + + /// + /// Tire size for this vehicle (e.g., "225/45 R17") + /// + [Required] + [MaxLength(50)] + public string TireSize { get; set; } = string.Empty; + + /// + /// Additional notes about the vehicle + /// + [MaxLength(500)] + public string? Notes { get; set; } + + /// + /// Whether this is the customer's primary/default vehicle + /// + public bool IsPrimary { get; set; } + + /// + /// Whether the vehicle is active (not deleted) + /// + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + /// + /// Navigation property for tire stacks stored for this vehicle + /// + public ICollection TireStacks { get; set; } = new List(); + + /// + /// Display name combining make, model, and tire size + /// + [NotMapped] + public string DisplayName + { + get + { + var name = Make; + if (!string.IsNullOrEmpty(Model)) + name += $" {Model}"; + if (!string.IsNullOrEmpty(TireSize)) + name += $" ({TireSize})"; + return name; + } + } + + /// + /// Short display showing just make (for compact views) + /// + [NotMapped] + public string ShortName => !string.IsNullOrEmpty(Model) + ? $"{Make} {Model}" + : Make; +} diff --git a/Lagerbestand/Program.cs b/Lagerbestand/Program.cs new file mode 100644 index 0000000..a48fca5 --- /dev/null +++ b/Lagerbestand/Program.cs @@ -0,0 +1,277 @@ +using Lagerbestand.Components; +using Lagerbestand.Data; +using Lagerbestand.Services; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Configure Kestrel to listen on all interfaces in production +if (!builder.Environment.IsDevelopment()) +{ + builder.WebHost.ConfigureKestrel(serverOptions => + { + // Listen on all network interfaces on port 5000 + serverOptions.ListenAnyIP(5000); + }); +} + +// Add Razor Components with interactive server-side rendering +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +// Circuit options — keep disconnected circuits alive longer to survive +// idle workshop tablets / brief network drops. Retention is read from +// SessionSettings (editable in Status page); default = 2 hours. +// Requires a restart to take effect after changing in Status. +var sessionRetentionHours = Lagerbestand.Models.SessionSettings.DefaultCircuitRetentionHours; +try +{ + var raw = builder.Configuration["SessionSettings:CircuitRetentionHours"]; + if (!string.IsNullOrWhiteSpace(raw) && double.TryParse(raw, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var cfgHours)) + { + sessionRetentionHours = cfgHours; + } +} +catch { /* ignore — use default */ } + +// Clamp to safe range 0.5h – 4h +if (sessionRetentionHours < Lagerbestand.Models.SessionSettings.MinCircuitRetentionHours) + sessionRetentionHours = Lagerbestand.Models.SessionSettings.MinCircuitRetentionHours; +if (sessionRetentionHours > Lagerbestand.Models.SessionSettings.MaxCircuitRetentionHours) + sessionRetentionHours = Lagerbestand.Models.SessionSettings.MaxCircuitRetentionHours; + +builder.Services.Configure(options => +{ + options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(sessionRetentionHours); + options.DisconnectedCircuitMaxRetained = 200; + options.JSInteropDefaultCallTimeout = TimeSpan.FromMinutes(3); + options.DetailedErrors = builder.Environment.IsDevelopment(); +}); + +// Response compression for better Core Web Vitals (production only) +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] + { + "application/javascript", + "text/css", + "application/json", + "text/html", + "image/svg+xml" + }); +}); + +builder.Services.Configure(options => +{ + options.Level = System.IO.Compression.CompressionLevel.Fastest; +}); + +// Configure HSTS (HTTP Strict Transport Security) +builder.Services.AddHsts(options => +{ + options.Preload = true; + options.IncludeSubDomains = true; + options.MaxAge = TimeSpan.FromDays(365); +}); + +// Configure MariaDB/MySQL connection with robust settings +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); + +// Use explicit MariaDB version to avoid connection during startup +// MariaDB 10.x is common for most hosting providers +var serverVersion = new MariaDbServerVersion(new Version(10, 6, 0)); + +builder.Services.AddDbContext(options => +{ + options.UseMySql(connectionString, serverVersion, mySqlOptions => + { + // Connection resiliency for production + mySqlOptions.EnableRetryOnFailure( + maxRetryCount: 10, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + + // Command timeout for slow connections + mySqlOptions.CommandTimeout(60); + + // Enable string comparison fix for MySQL + mySqlOptions.EnableStringComparisonTranslations(); + }); + + // Enable detailed errors only in development + if (builder.Environment.IsDevelopment()) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + } +}); + +// Register application services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +// Register tire storage management services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Register Kanban service +builder.Services.AddScoped(); + +// Lexware API sync service +builder.Services.AddHttpClient("LexwareApi"); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + + // HTTPS redirection and HSTS for production + app.UseHttpsRedirection(); + app.UseHsts(); + + // Enable response compression only in production + // (Disabled in development to allow Browser Link/Hot Reload script injection) + app.UseResponseCompression(); +} + +// Security headers to prevent clickjacking, MIME sniffing, and control referrer/permissions +app.Use(async (context, next) => +{ + context.Response.Headers.Append("X-Frame-Options", "DENY"); + context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); + context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); + context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + + if (IsPrivateOrUtilityPath(context.Request.Path)) + { + context.Response.Headers.Append("X-Robots-Tag", "noindex, nofollow, noarchive"); + } + + await next(); +}); + +static bool IsPrivateOrUtilityPath(PathString path) +{ + return path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/login", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/tire-storage", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/tire-checkin", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/tire-checkout", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/customers", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/storage-locations", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/users", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/status", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/weather", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/_blazor", StringComparison.OrdinalIgnoreCase); +} + +// Static files with caching headers for performance +app.UseStaticFiles(new StaticFileOptions +{ + OnPrepareResponse = ctx => + { + // Cache static assets for 1 year (immutable) - production only + if (!app.Environment.IsDevelopment()) + { + if (ctx.File.Name.EndsWith(".css") || + ctx.File.Name.EndsWith(".js") || + ctx.File.Name.EndsWith(".woff2") || + ctx.File.Name.EndsWith(".woff") || + ctx.File.Name.EndsWith(".png") || + ctx.File.Name.EndsWith(".jpg") || + ctx.File.Name.EndsWith(".svg")) + { + ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000, immutable"); + } + else if (ctx.File.Name.EndsWith(".html") || + ctx.File.Name.EndsWith(".xml") || + ctx.File.Name.EndsWith(".txt")) + { + ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=3600"); + } + } + } +}); + +app.UseAntiforgery(); + +// Initialize database on startup +using (var scope = app.Services.CreateScope()) +{ + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.InitializeAsync(); + + // Apply DB-stored circuit retention (overrides config default). + // Effective on next restart after saving in the Status page. + try + { + var settingsService = scope.ServiceProvider.GetRequiredService(); + var sessionSettings = await settingsService.GetSessionSettingsAsync(); + var effectiveHours = sessionSettings.GetSafeCircuitRetentionHours(); + var circuitOptions = app.Services.GetRequiredService>().Value; + circuitOptions.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(effectiveHours); + } + catch + { + // Non-fatal — use compile-time default + } +} + +// Session settings endpoint — consumed by app-interop.js to configure +// the idle-lock timeout in the browser without needing a JS-interop call. +app.MapGet("/api/session-settings", async (ISiteSettingsService settingsService) => +{ + var s = await settingsService.GetSessionSettingsAsync(); + return Results.Json(new + { + idleTimeoutMinutes = s.GetSafeIdleTimeoutMinutes(), + circuitRetentionHours = s.GetSafeCircuitRetentionHours() + }); +}); + +// Catalog image endpoint (serves images from NFS/file storage) +app.MapGet("/api/catalog-image/{*relativePath}", async (string relativePath, IFileStorageService fileStorage) => +{ + if (string.IsNullOrWhiteSpace(relativePath)) + return Results.NotFound(); + + var fileBytes = await fileStorage.ReadFileAsync(relativePath); + if (fileBytes == null) + return Results.NotFound(); + + var extension = Path.GetExtension(relativePath).ToLowerInvariant(); + var contentType = extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + + return Results.File(fileBytes, contentType); +}); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/Lagerbestand/Properties/PublishProfiles/FolderProfile.pubxml b/Lagerbestand/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..ac657ee --- /dev/null +++ b/Lagerbestand/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,20 @@ + + + + + true + false + true + Release + Any CPU + FileSystem + C:\local\Neuer Ordner + FileSystem + <_TargetId>Folder + + net8.0 + 760ca6f8-7ba8-4c6f-b464-f5aed2329aeb + true + win-x64 + + \ No newline at end of file diff --git a/Lagerbestand/Properties/launchSettings.json b/Lagerbestand/Properties/launchSettings.json new file mode 100644 index 0000000..7be590f --- /dev/null +++ b/Lagerbestand/Properties/launchSettings.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:19896", + "sslPort": 44334 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7206;http://localhost:5158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Production": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Lagerbestand/Services/AuthService.cs b/Lagerbestand/Services/AuthService.cs new file mode 100644 index 0000000..ae71f07 --- /dev/null +++ b/Lagerbestand/Services/AuthService.cs @@ -0,0 +1,477 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; + +namespace Lagerbestand.Services; + +public interface IAuthService +{ + Task LoginAsync(string username, string password); + Task AuthenticateAsync(string username, string password); + Task GetUserByIdAsync(Guid id); + Task> GetAllUsersAsync(); + Task CreateUserAsync(string username, string password, string? displayName, UserRole role); + Task UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive); + Task ChangePasswordAsync(Guid id, string newPassword); + Task DeleteUserAsync(Guid id); + Task EnsureAdminExistsAsync(); + Task UsernameExistsAsync(string username, Guid? excludeUserId = null); + Task GetActiveAdminCountAsync(); + (bool IsValid, string? ErrorMessage) ValidatePassword(string password); + (bool IsValid, string? ErrorMessage) ValidateUsername(string username); + + // 2FA methods + Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId); + Task Confirm2FaAsync(Guid userId, string code); + Task Disable2FaAsync(Guid userId); + Task Reset2FaAsync(Guid userId); + Task SetEnforce2FaAsync(Guid userId, bool enforce); +} + +public class AuthService : IAuthService +{ + private const string LegacyPasswordSalt = "ReifenSpaethSalt2025"; + private const int Pbkdf2Iterations = 100_000; + private const int SaltSize = 16; + private const int HashSize = 32; + private const int MinPasswordLength = 4; + private const int MaxPasswordLength = 128; + private const int MinUsernameLength = 3; + private const int MaxUsernameLength = 50; + private const int MaxFailedLoginAttempts = 5; + private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(5); + private static readonly System.Collections.Concurrent.ConcurrentDictionary FailedLoginAttempts = new(); + + private readonly AppDbContext _context; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly ITwoFactorService _twoFactorService; + private readonly ISiteSettingsService _siteSettingsService; + + public AuthService(AppDbContext context, IConfiguration configuration, ILogger logger, + ITwoFactorService twoFactorService, ISiteSettingsService siteSettingsService) + { + _context = context; + _configuration = configuration; + _logger = logger; + _twoFactorService = twoFactorService; + _siteSettingsService = siteSettingsService; + } + + public async Task LoginAsync(string username, string password) + { + var lockoutKey = username.Trim().ToLowerInvariant(); + if (IsLockedOut(lockoutKey)) + { + _logger.LogWarning("Login blocked due to too many failed attempts for username: {Username}", username); + return null; + } + + var user = await GetActiveUserByUsernameAsync(username); + if (user == null || !VerifyPassword(password, user.PasswordHash)) + { + RegisterFailedAttempt(lockoutKey); + _logger.LogWarning("Failed login attempt for username: {Username}", username); + return null; + } + + FailedLoginAttempts.TryRemove(lockoutKey, out _); + + // Re-hash legacy SHA256 passwords to PBKDF2 + await ReHashIfLegacyAsync(user, password); + + user.LastLoginAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + _logger.LogInformation("User {Username} logged in successfully", username); + return user; + } + + private static bool IsLockedOut(string key) + { + if (FailedLoginAttempts.TryGetValue(key, out var entry)) + { + if (entry.Count >= MaxFailedLoginAttempts && DateTime.UtcNow - entry.LastAttempt < LockoutDuration) + return true; + + if (DateTime.UtcNow - entry.LastAttempt >= LockoutDuration) + FailedLoginAttempts.TryRemove(key, out _); + } + + return false; + } + + private static void RegisterFailedAttempt(string key) + { + FailedLoginAttempts.AddOrUpdate(key, + _ => (1, DateTime.UtcNow), + (_, existing) => (existing.Count + 1, DateTime.UtcNow)); + } + + public async Task AuthenticateAsync(string username, string password) + { + var user = await GetActiveUserByUsernameAsync(username); + if (user == null || !VerifyPassword(password, user.PasswordHash)) + return null; + + return user; + } + + public async Task GetUserByIdAsync(Guid id) + { + return await _context.Users.FindAsync(id); + } + + public async Task> GetAllUsersAsync() + { + return await _context.Users + .OrderBy(u => u.Username) + .ToListAsync(); + } + + public async Task UsernameExistsAsync(string username, Guid? excludeUserId = null) + { + var normalizedUsername = username.Trim().ToLowerInvariant(); + var query = _context.Users.Where(u => u.Username.ToLower() == normalizedUsername); + + if (excludeUserId.HasValue) + query = query.Where(u => u.Id != excludeUserId.Value); + + return await query.AnyAsync(); + } + + public async Task GetActiveAdminCountAsync() + { + return await _context.Users + .CountAsync(u => u.Role == UserRole.Admin && u.IsActive); + } + + public (bool IsValid, string? ErrorMessage) ValidatePassword(string password) + { + if (string.IsNullOrWhiteSpace(password)) + return (false, "Passwort ist erforderlich."); + + if (password.Length < MinPasswordLength) + return (false, $"Passwort muss mindestens {MinPasswordLength} Zeichen lang sein."); + + if (password.Length > MaxPasswordLength) + return (false, $"Passwort darf maximal {MaxPasswordLength} Zeichen lang sein."); + + return (true, null); + } + + public (bool IsValid, string? ErrorMessage) ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + return (false, "Benutzername ist erforderlich."); + + var trimmed = username.Trim(); + + if (trimmed.Length < MinUsernameLength) + return (false, $"Benutzername muss mindestens {MinUsernameLength} Zeichen lang sein."); + + if (trimmed.Length > MaxUsernameLength) + return (false, $"Benutzername darf maximal {MaxUsernameLength} Zeichen lang sein."); + + // Allow only alphanumeric, dots, underscores, hyphens + if (!trimmed.All(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-')) + return (false, "Benutzername darf nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten."); + + return (true, null); + } + + public async Task CreateUserAsync(string username, string password, string? displayName, UserRole role) + { + var usernameValidation = ValidateUsername(username); + if (!usernameValidation.IsValid) + throw new InvalidOperationException(usernameValidation.ErrorMessage); + + var passwordValidation = ValidatePassword(password); + if (!passwordValidation.IsValid) + throw new InvalidOperationException(passwordValidation.ErrorMessage); + + var normalizedUsername = username.Trim(); + + if (await UsernameExistsAsync(normalizedUsername)) + throw new InvalidOperationException($"Benutzer '{normalizedUsername}' existiert bereits."); + + var user = new AppUser + { + Username = normalizedUsername, + PasswordHash = HashPassword(password), + DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + _logger.LogInformation("User {Username} created with role {Role}", normalizedUsername, role); + return user; + } + + public async Task UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive) + { + var user = await _context.Users.FindAsync(id) + ?? throw new InvalidOperationException("Benutzer nicht gefunden."); + + // Prevent deactivating or demoting the last admin + if (user.Role == UserRole.Admin && (role != UserRole.Admin || !isActive)) + { + var adminCount = await GetActiveAdminCountAsync(); + if (adminCount <= 1) + throw new InvalidOperationException("Der letzte aktive Administrator kann nicht deaktiviert oder zu einer anderen Rolle geändert werden."); + } + + // Update username if provided and different + if (!string.IsNullOrWhiteSpace(username)) + { + var newUsername = username.Trim(); + if (!newUsername.Equals(user.Username, StringComparison.OrdinalIgnoreCase)) + { + var usernameValidation = ValidateUsername(newUsername); + if (!usernameValidation.IsValid) + throw new InvalidOperationException(usernameValidation.ErrorMessage); + + if (await UsernameExistsAsync(newUsername, id)) + throw new InvalidOperationException($"Benutzer '{newUsername}' existiert bereits."); + + user.Username = newUsername; + } + } + + user.DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(); + user.Role = role; + user.IsActive = isActive; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("User {UserId} updated", id); + return user; + } + + public async Task ChangePasswordAsync(Guid id, string newPassword) + { + var passwordValidation = ValidatePassword(newPassword); + if (!passwordValidation.IsValid) + throw new InvalidOperationException(passwordValidation.ErrorMessage); + + var user = await _context.Users.FindAsync(id); + if (user == null) + return false; + + user.PasswordHash = HashPassword(newPassword); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Password changed for user {UserId}", id); + return true; + } + + public async Task DeleteUserAsync(Guid id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + return; + + // Prevent deleting the last admin + if (user.Role == UserRole.Admin && user.IsActive) + { + var adminCount = await GetActiveAdminCountAsync(); + if (adminCount <= 1) + throw new InvalidOperationException("Der letzte aktive Administrator kann nicht gelöscht werden."); + } + + // Soft delete + user.IsActive = false; + await _context.SaveChangesAsync(); + + _logger.LogInformation("User {UserId} ({Username}) deactivated", id, user.Username); + } + + public async Task EnsureAdminExistsAsync() + { + try + { + var adminUsername = _configuration["AdminUser:Username"] ?? "admin"; + var adminPassword = _configuration["AdminUser:Password"] ?? "admin123"; + var adminDisplayName = _configuration["AdminUser:DisplayName"] ?? "Administrator"; + + var existingAdmin = await _context.Users + .FirstOrDefaultAsync(u => u.Username.ToLower() == adminUsername.ToLower()); + + if (existingAdmin == null) + { + var admin = new AppUser + { + Username = adminUsername, + PasswordHash = HashPassword(adminPassword), + DisplayName = adminDisplayName, + Role = UserRole.Admin, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + _context.Users.Add(admin); + await _context.SaveChangesAsync(); + _logger.LogInformation("Admin user '{Username}' created.", adminUsername); + } + else + { + existingAdmin.PasswordHash = HashPassword(adminPassword); + existingAdmin.DisplayName = adminDisplayName; + existingAdmin.Role = UserRole.Admin; + existingAdmin.IsActive = true; + await _context.SaveChangesAsync(); + _logger.LogInformation("Admin user '{Username}' updated.", adminUsername); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating/updating admin user."); + } + } + + private async Task GetActiveUserByUsernameAsync(string username) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower() && u.IsActive); + } + + private static string HashPassword(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + salt, + Pbkdf2Iterations, + HashAlgorithmName.SHA256, + HashSize); + return $"{Pbkdf2Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + } + + private static bool VerifyPassword(string password, string storedHash) + { + // New PBKDF2 format: "iterations.salt.hash" + if (storedHash.Contains('.')) + { + var parts = storedHash.Split('.', 3); + if (parts.Length == 3 + && int.TryParse(parts[0], out var iterations)) + { + var salt = Convert.FromBase64String(parts[1]); + var expectedHash = Convert.FromBase64String(parts[2]); + var computedHash = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + salt, + iterations, + HashAlgorithmName.SHA256, + expectedHash.Length); + return CryptographicOperations.FixedTimeEquals(computedHash, expectedHash); + } + } + + // Legacy SHA256 + hardcoded salt fallback + var legacyBytes = Encoding.UTF8.GetBytes(password + LegacyPasswordSalt); + var legacyHash = SHA256.HashData(legacyBytes); + return Convert.ToBase64String(legacyHash) == storedHash; + } + + /// + /// Re-hashes a legacy password to the new PBKDF2 format on successful login. + /// + private async Task ReHashIfLegacyAsync(AppUser user, string password) + { + // If the stored hash is in legacy format (no dots), upgrade it + if (!user.PasswordHash.Contains('.')) + { + user.PasswordHash = HashPassword(password); + await _context.SaveChangesAsync(); + _logger.LogInformation("Re-hashed legacy password for user {Username}", user.Username); + } + } + + #region Two-Factor Authentication + + public async Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId) + { + var user = await _context.Users.FindAsync(userId) + ?? throw new InvalidOperationException("Benutzer nicht gefunden."); + + var tfSettings = await _siteSettingsService.GetTwoFactorSettingsAsync(); + if (!tfSettings.IsEnabled) + throw new InvalidOperationException("Zwei-Faktor-Authentifizierung ist global deaktiviert."); + + var secret = _twoFactorService.GenerateSecretKey(); + var otpAuthUri = _twoFactorService.GenerateOtpAuthUri(secret, user.Username, tfSettings.Issuer); + + // Store the secret but don't enable 2FA yet – user must confirm with a code first + user.TwoFactorSecret = secret; + await _context.SaveChangesAsync(); + + _logger.LogInformation("2FA setup initiated for user {Username}", user.Username); + return (secret, otpAuthUri); + } + + public async Task Confirm2FaAsync(Guid userId, string code) + { + var user = await _context.Users.FindAsync(userId) + ?? throw new InvalidOperationException("Benutzer nicht gefunden."); + + if (string.IsNullOrEmpty(user.TwoFactorSecret)) + throw new InvalidOperationException("Kein 2FA-Secret vorhanden. Bitte 2FA zuerst einrichten."); + + if (!_twoFactorService.ValidateTotpCode(user.TwoFactorSecret, code)) + return false; + + user.TwoFactorEnabled = true; + await _context.SaveChangesAsync(); + + _logger.LogInformation("2FA confirmed and enabled for user {Username}", user.Username); + return true; + } + + public async Task Disable2FaAsync(Guid userId) + { + var user = await _context.Users.FindAsync(userId) + ?? throw new InvalidOperationException("Benutzer nicht gefunden."); + + if (user.TwoFactorEnforced) + throw new InvalidOperationException("2FA wurde vom Administrator erzwungen und kann nicht deaktiviert werden."); + + user.TwoFactorEnabled = false; + user.TwoFactorSecret = null; + await _context.SaveChangesAsync(); + + _logger.LogInformation("2FA disabled for user {Username}", user.Username); + } + + public async Task Reset2FaAsync(Guid userId) + { + var user = await _context.Users.FindAsync(userId) + ?? throw new InvalidOperationException("Benutzer nicht gefunden."); + + user.TwoFactorEnabled = false; + user.TwoFactorSecret = null; + user.TwoFactorEnforced = false; + await _context.SaveChangesAsync(); + + _logger.LogInformation("2FA reset by admin for user {Username}", user.Username); + } + + public async Task SetEnforce2FaAsync(Guid userId, bool enforce) + { + var user = await _context.Users.FindAsync(userId) + ?? throw new InvalidOperationException("Benutzer nicht gefunden."); + + user.TwoFactorEnforced = enforce; + await _context.SaveChangesAsync(); + + _logger.LogInformation("2FA enforce set to {Enforce} for user {Username}", enforce, user.Username); + } + + #endregion +} diff --git a/Lagerbestand/Services/CatalogService.cs b/Lagerbestand/Services/CatalogService.cs new file mode 100644 index 0000000..ebb3be8 --- /dev/null +++ b/Lagerbestand/Services/CatalogService.cs @@ -0,0 +1,114 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +public interface ICatalogService +{ + Task> GetAllAsync(); + Task GetByIdAsync(Guid id); + Task CreateAsync(CatalogItem item); + Task UpdateAsync(CatalogItem item); + Task DeleteAsync(Guid id); + Task SaveImageAsync(Stream imageStream, string fileName); + Task DeleteImageAsync(string? fileName); +} + +public class CatalogService : ICatalogService +{ + private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; + + private readonly AppDbContext _context; + private readonly ILogger _logger; + private readonly string _uploadPath; + + public CatalogService(AppDbContext context, IWebHostEnvironment environment, ILogger logger) + { + _context = context; + _logger = logger; + _uploadPath = Path.Combine(environment.WebRootPath, "uploads", "images"); + + Directory.CreateDirectory(_uploadPath); + } + + public async Task> GetAllAsync() + { + return await _context.CatalogItems + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.CatalogItems.FindAsync(id); + } + + public async Task CreateAsync(CatalogItem item) + { + item.Id = Guid.NewGuid(); + item.CreatedAt = DateTime.UtcNow; + + _context.CatalogItems.Add(item); + await _context.SaveChangesAsync(); + + return item; + } + + public async Task UpdateAsync(CatalogItem item) + { + var existing = await _context.CatalogItems.FindAsync(item.Id) + ?? throw new InvalidOperationException("Artikel nicht gefunden"); + + existing.Name = item.Name; + existing.TireSize = item.TireSize; + existing.Season = item.Season; + existing.Type = item.Type; + existing.PictureFileName = item.PictureFileName; + existing.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + return existing; + } + + public async Task DeleteAsync(Guid id) + { + var item = await _context.CatalogItems.FindAsync(id); + if (item != null) + { + await DeleteImageAsync(item.PictureFileName); + _context.CatalogItems.Remove(item); + await _context.SaveChangesAsync(); + } + } + + public async Task SaveImageAsync(Stream imageStream, string fileName) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + + if (!AllowedImageExtensions.Contains(extension)) + throw new InvalidOperationException("Ungültiger Dateityp. Erlaubt: jpg, jpeg, png, gif, webp"); + + var uniqueFileName = $"{Guid.NewGuid()}{extension}"; + var filePath = Path.Combine(_uploadPath, uniqueFileName); + + await using var fileStream = new FileStream(filePath, FileMode.Create); + await imageStream.CopyToAsync(fileStream); + + return uniqueFileName; + } + + public Task DeleteImageAsync(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) + return Task.CompletedTask; + + var filePath = Path.Combine(_uploadPath, fileName); + + if (File.Exists(filePath)) + File.Delete(filePath); + + return Task.CompletedTask; + } +} diff --git a/Lagerbestand/Services/CustomerService.cs b/Lagerbestand/Services/CustomerService.cs new file mode 100644 index 0000000..ea35e9a --- /dev/null +++ b/Lagerbestand/Services/CustomerService.cs @@ -0,0 +1,315 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +public interface ICustomerService +{ + Task> GetAllAsync(bool includeInactive = false); + Task GetByIdAsync(Guid id); + Task GetByCustomerNumberAsync(string customerNumber); + Task> SearchAsync(string searchTerm); + Task FindByVehicleAsync(string searchTerm); + Task CreateAsync(Customer customer); + Task UpdateAsync(Customer customer); + Task DeleteAsync(Guid id); + Task GenerateCustomerNumberAsync(); + Task GetStoredTireStackCountAsync(Guid customerId); + + // Vehicle methods + Task> GetVehiclesAsync(Guid customerId); + Task GetVehicleByIdAsync(Guid vehicleId); + Task AddVehicleAsync(Guid customerId, Vehicle vehicle); + Task UpdateVehicleAsync(Vehicle vehicle); + Task DeleteVehicleAsync(Guid vehicleId); + Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId); + Task CreateWithVehicleAsync(Customer customer, Vehicle vehicle); +} + +public class CustomerService : ICustomerService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public CustomerService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task> GetAllAsync(bool includeInactive = false) + { + var query = _context.Customers + .Include(c => c.Vehicles.Where(v => v.IsActive)) + .AsQueryable(); + + if (!includeInactive) + query = query.Where(c => c.IsActive); + + return await query + .OrderBy(c => c.LastName) + .ThenBy(c => c.FirstName) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.Customers + .Include(c => c.Vehicles.Where(v => v.IsActive)) + .Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored)) + .ThenInclude(ts => ts.StorageLocation) + .Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored)) + .ThenInclude(ts => ts.Vehicle) + .AsSplitQuery() + .FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task GetByCustomerNumberAsync(string customerNumber) + { + return await _context.Customers + .Include(c => c.Vehicles.Where(v => v.IsActive)) + .FirstOrDefaultAsync(c => c.CustomerNumber == customerNumber); + } + + public async Task> SearchAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return await GetAllAsync(); + + var term = searchTerm.Trim().ToLower(); + + return await _context.Customers + .AsNoTracking() + .AsSplitQuery() + .Include(c => c.Vehicles.Where(v => v.IsActive)) + .Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored)) + .Where(c => c.IsActive && ( + c.FirstName != null && c.FirstName.ToLower().Contains(term) || + c.LastName.ToLower().Contains(term) || + (c.Company != null && c.Company.ToLower().Contains(term)) || + c.CustomerNumber.ToLower().Contains(term) || + (c.Phone != null && c.Phone.Contains(term)) || + // Search in vehicles + c.Vehicles.Any(v => v.IsActive && ( + v.Make.ToLower().Contains(term) || + (v.Model != null && v.Model.ToLower().Contains(term)))) + )) + .OrderBy(c => c.LastName) + .ThenBy(c => c.FirstName) + .Take(50) + .ToListAsync(); + } + + public async Task FindByVehicleAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return null; + + var term = searchTerm.Trim().ToLower(); + + // Server-side search in Vehicles table by Make or Model + var matchingVehicle = await _context.Vehicles + .Include(v => v.Customer) + .Where(v => v.IsActive && v.Customer != null && v.Customer.IsActive) + .FirstOrDefaultAsync(v => + v.Make.ToLower().Contains(term) || + (v.Model != null && v.Model.ToLower().Contains(term))); + + if (matchingVehicle?.Customer != null) + { + return await GetByIdAsync(matchingVehicle.Customer.Id); + } + + return null; + } + + public async Task CreateAsync(Customer customer) + { + if (string.IsNullOrEmpty(customer.CustomerNumber)) + customer.CustomerNumber = await GenerateCustomerNumberAsync(); + + customer.CreatedAt = DateTime.UtcNow; + + _context.Customers.Add(customer); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Customer created: {CustomerNumber} - {Name}", + customer.CustomerNumber, customer.FullName); + + return customer; + } + + public async Task CreateWithVehicleAsync(Customer customer, Vehicle vehicle) + { + if (string.IsNullOrEmpty(customer.CustomerNumber)) + customer.CustomerNumber = await GenerateCustomerNumberAsync(); + + customer.CreatedAt = DateTime.UtcNow; + vehicle.IsPrimary = true; + vehicle.CreatedAt = DateTime.UtcNow; + customer.Vehicles.Add(vehicle); + + _context.Customers.Add(customer); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Customer created with vehicle: {CustomerNumber} - {Name}, Vehicle: {Make}", + customer.CustomerNumber, customer.FullName, vehicle.Make); + + return customer; + } + + public async Task UpdateAsync(Customer customer) + { + customer.UpdatedAt = DateTime.UtcNow; + _context.Customers.Update(customer); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Customer updated: {CustomerNumber}", customer.CustomerNumber); + + return customer; + } + + public async Task DeleteAsync(Guid id) + { + var customer = await _context.Customers.FindAsync(id); + if (customer == null) + return false; + + // Soft delete - just mark as inactive + customer.IsActive = false; + customer.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Customer deactivated: {CustomerNumber}", customer.CustomerNumber); + + return true; + } + + public async Task GenerateCustomerNumberAsync() + { + var year = DateTime.UtcNow.Year; + var prefix = $"K-{year}-"; + + var lastNumber = await _context.Customers + .Where(c => c.CustomerNumber.StartsWith(prefix)) + .OrderByDescending(c => c.CustomerNumber) + .Select(c => c.CustomerNumber) + .FirstOrDefaultAsync(); + + var nextNumber = 1; + if (lastNumber != null) + { + var numPart = lastNumber.Replace(prefix, ""); + if (int.TryParse(numPart, out var lastNum)) + nextNumber = lastNum + 1; + } + + return $"{prefix}{nextNumber:D4}"; + } + + public async Task GetStoredTireStackCountAsync(Guid customerId) + { + return await _context.TireStacks + .CountAsync(ts => ts.IsActive && ts.CustomerId == customerId && ts.Status == TireStackStatus.Stored); + } + + // Vehicle methods + + public async Task> GetVehiclesAsync(Guid customerId) + { + return await _context.Vehicles + .Where(v => v.CustomerId == customerId && v.IsActive) + .OrderByDescending(v => v.IsPrimary) + .ThenBy(v => v.CreatedAt) + .ToListAsync(); + } + + public async Task GetVehicleByIdAsync(Guid vehicleId) + { + return await _context.Vehicles + .Include(v => v.Customer) + .Include(v => v.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored)) + .FirstOrDefaultAsync(v => v.Id == vehicleId); + } + + public async Task AddVehicleAsync(Guid customerId, Vehicle vehicle) + { + vehicle.CustomerId = customerId; + vehicle.CreatedAt = DateTime.UtcNow; + + // If this is the first vehicle, make it primary + var existingVehicles = await _context.Vehicles + .CountAsync(v => v.CustomerId == customerId && v.IsActive); + + if (existingVehicles == 0) + vehicle.IsPrimary = true; + + _context.Vehicles.Add(vehicle); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Vehicle added for customer {CustomerId}: {Make}", + customerId, vehicle.Make); + + return vehicle; + } + + public async Task UpdateVehicleAsync(Vehicle vehicle) + { + vehicle.UpdatedAt = DateTime.UtcNow; + _context.Vehicles.Update(vehicle); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Vehicle updated: {VehicleId} - {Make}", + vehicle.Id, vehicle.Make); + + return vehicle; + } + + public async Task DeleteVehicleAsync(Guid vehicleId) + { + var vehicle = await _context.Vehicles.FindAsync(vehicleId); + if (vehicle == null) + return false; + + // Soft delete + vehicle.IsActive = false; + vehicle.UpdatedAt = DateTime.UtcNow; + + // If this was primary, assign primary to another vehicle + if (vehicle.IsPrimary) + { + vehicle.IsPrimary = false; + var nextVehicle = await _context.Vehicles + .Where(v => v.CustomerId == vehicle.CustomerId && v.IsActive && v.Id != vehicleId) + .FirstOrDefaultAsync(); + + if (nextVehicle != null) + nextVehicle.IsPrimary = true; + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Vehicle deactivated: {VehicleId}", vehicleId); + + return true; + } + + public async Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId) + { + var vehicles = await _context.Vehicles + .Where(v => v.CustomerId == customerId && v.IsActive) + .ToListAsync(); + + foreach (var v in vehicles) + { + v.IsPrimary = v.Id == vehicleId; + v.UpdatedAt = DateTime.UtcNow; + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Primary vehicle set for customer {CustomerId}: {VehicleId}", + customerId, vehicleId); + } +} diff --git a/Lagerbestand/Services/DatabaseInitializer.cs b/Lagerbestand/Services/DatabaseInitializer.cs new file mode 100644 index 0000000..6f37ce6 --- /dev/null +++ b/Lagerbestand/Services/DatabaseInitializer.cs @@ -0,0 +1,1383 @@ +using Lagerbestand.Data; +using Microsoft.EntityFrameworkCore; +using MySqlConnector; + +namespace Lagerbestand.Services; + +public interface IDatabaseInitializer +{ + Task InitializeAsync(CancellationToken cancellationToken = default); +} + +public class DatabaseInitializer : IDatabaseInitializer +{ + private const int MaxRetries = 15; + private const int InitialRetryDelaySeconds = 2; + private const int MaxRetryDelaySeconds = 30; + + private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public DatabaseInitializer( + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger) + { + _serviceProvider = serviceProvider; + _configuration = configuration; + _logger = logger; + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + var connectionString = _configuration.GetConnectionString("DefaultConnection"); + _logger.LogInformation("Starte Datenbankinitialisierung für Server: {Server}", + GetServerFromConnectionString(connectionString)); + + var retryDelay = InitialRetryDelaySeconds; + + for (var attempt = 1; attempt <= MaxRetries; attempt++) + { + try + { + _logger.LogInformation("Datenbankverbindung wird hergestellt (Versuch {Attempt}/{MaxRetries})...", + attempt, MaxRetries); + + // First ensure the database exists + await EnsureDatabaseExistsAsync(cancellationToken); + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Test connection with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + + if (await context.Database.CanConnectAsync(cts.Token)) + { + _logger.LogInformation("Datenbankverbindung erfolgreich hergestellt."); + + await CreateTablesAsync(cancellationToken); + + var authService = scope.ServiceProvider.GetRequiredService(); + await authService.EnsureAdminExistsAsync(); + + var storageLocationService = scope.ServiceProvider.GetRequiredService(); + var seeded = await storageLocationService.SeedDefaultLocationsAsync(); + if (seeded > 0) + _logger.LogInformation("{Count} Standard-Lagerplätze angelegt.", seeded); + + _logger.LogInformation("Datenbankinitialisierung abgeschlossen."); + return; + } + + _logger.LogWarning("Datenbankverbindung fehlgeschlagen (CanConnect returned false)."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("Datenbankinitialisierung wurde abgebrochen."); + throw; + } + catch (MySqlException ex) + { + _logger.LogWarning(ex, "MySQL Fehler bei Datenbankinitialisierung (Versuch {Attempt}/{MaxRetries}): {Message}", + attempt, MaxRetries, ex.Message); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Datenbankinitialisierung fehlgeschlagen (Versuch {Attempt}/{MaxRetries}): {Message}", + attempt, MaxRetries, ex.Message); + } + + if (attempt < MaxRetries) + { + _logger.LogInformation("Warte {Delay} Sekunden vor dem nächsten Versuch...", retryDelay); + await Task.Delay(TimeSpan.FromSeconds(retryDelay), cancellationToken); + + // Exponential backoff with max limit + retryDelay = Math.Min(retryDelay * 2, MaxRetryDelaySeconds); + } + } + + _logger.LogError("Konnte keine Datenbankverbindung herstellen nach {MaxRetries} Versuchen. " + + "Bitte überprüfen Sie die Verbindungseinstellungen und die Erreichbarkeit des Datenbankservers.", MaxRetries); + } + + private static string? GetServerFromConnectionString(string? connectionString) + { + if (string.IsNullOrEmpty(connectionString)) return null; + + foreach (var part in connectionString.Split(';')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0].Trim().Equals("Server", StringComparison.OrdinalIgnoreCase)) + return kv[1].Trim(); + } + return null; + } + + private async Task CreateTablesAsync(CancellationToken cancellationToken) + { + var connectionString = _configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string nicht gefunden."); + + await using var connection = new MySqlConnection(connectionString); + + // Set connection timeout + connection.ConnectionTimeout.ToString(); + + await connection.OpenAsync(cancellationToken); + + // Core tables + await ExecuteSqlSafeAsync(connection, CreateCatalogItemsTableSql, "CatalogItems", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateInventoryItemsTableSql, "InventoryItems", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateUsersTableSql, "Users", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateSiteSettingsTableSql, "SiteSettings", cancellationToken); + + // Tire management tables + await ExecuteSqlSafeAsync(connection, CreateCustomersTableSql, "Customers", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateVehiclesTableSql, "Vehicles", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateStorageLocationsTableSql, "StorageLocations", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateTireStacksTableSql, "TireStacks", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateTireItemsTableSql, "TireItems", cancellationToken); + + // Kanban tables + await ExecuteSqlSafeAsync(connection, CreateKanbanColumnsTableSql, "KanbanColumns", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanLabelsTableSql, "KanbanLabels", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTasksTableSql, "KanbanTasks", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTodosTableSql, "KanbanTaskTodos", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskCommentsTableSql, "KanbanTaskComments", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskNotesTableSql, "KanbanTaskNotes", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskAttachmentsTableSql, "KanbanTaskAttachments", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTemplatesTableSql, "KanbanTaskTemplates", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTemplateTodosTableSql, "KanbanTaskTemplateTodos", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskLabelsTableSql, "KanbanTaskLabels", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTemplateLabelsTableSql, "KanbanTaskTemplateLabels", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskAssigneesTableSql, "KanbanTaskAssignees", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskCustomersTableSql, "KanbanTaskCustomers", cancellationToken); + await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTireStacksTableSql, "KanbanTaskTireStacks", cancellationToken); + + // Initialize default Kanban columns + await InitializeDefaultKanbanColumnsAsync(connection, cancellationToken); + + // Migration: Add VehicleId column to TireStacks if it doesn't exist + await MigrateAddVehicleIdToTireStacksAsync(connection, cancellationToken); + + // Migration: Migrate customer vehicle data to Vehicles table + await MigrateCustomerVehiclesToVehiclesTableAsync(connection, cancellationToken); + + // Migration: Migrate old TireSets table to TireStacks if it exists + await MigrateTireSetsToTireStacksAsync(connection, cancellationToken); + + // Migration: Migrate old StorageLocations schema to new ABCD format + await MigrateStorageLocationsSchemaAsync(connection, cancellationToken); + + // Migration: Add Position column (Gebäude/Reihe/Seite-Ebene/Platz format) and reset test data + await MigrateStorageLocationsPositionAsync(connection, cancellationToken); + + // Migration: Add FilePath column to KanbanTaskAttachments for external file storage + await MigrateAddFilePathToKanbanTaskAttachmentsAsync(connection, cancellationToken); + + // Migration: Add NotesText column to KanbanTasks for simple task notes + await MigrateAddNotesTextToKanbanTasksAsync(connection, cancellationToken); + + // Migration: Add IsActive column to TireStacks for soft delete + await MigrateAddIsActiveToTireStacksAsync(connection, cancellationToken); + + // Migration: Remove LicensePlate/Year from Vehicles, add TireSize, make Make NOT NULL + await MigrateVehiclesRemoveLicensePlateAsync(connection, cancellationToken); + + // Migration: Rename Kanban column "Warte auf wen anders" to "Warte auf Kunden" + await MigrateRenameKanbanWaitColumnAsync(connection, cancellationToken); + + // Migration: Add TireSizeNote column to KanbanTasks for quick tire size notes + await MigrateAddTireSizeNoteToKanbanTasksAsync(connection, cancellationToken); + + // Migration: Fix broken Kanban column icons (corrupted emoji in seed data) + await MigrateFixKanbanColumnIconsAsync(connection, cancellationToken); + + // Migration: Add VehicleId column to KanbanTaskCustomers for vehicle selection + await MigrateAddVehicleIdToKanbanTaskCustomersAsync(connection, cancellationToken); + + // Migration: Make Customer FirstName nullable + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Customers` MODIFY COLUMN `FirstName` VARCHAR(100) NULL;", + "MakeCustomerFirstNameNullable", cancellationToken); + + // Migration: Add 2FA columns to Users table + await MigrateAdd2FaColumnsToUsersAsync(connection, cancellationToken); + + // Cleanup: Drop legacy Customer columns (LicensePlate, VehicleMake, VehicleModel) + await DropColumnIfExistsAsync(connection, "Customers", "LicensePlate", cancellationToken); + await DropColumnIfExistsAsync(connection, "Customers", "VehicleMake", cancellationToken); + await DropColumnIfExistsAsync(connection, "Customers", "VehicleModel", cancellationToken); + await ExecuteSqlSafeAsync(connection, "ALTER TABLE `Customers` DROP INDEX `IX_Customers_LicensePlate`;", "DropCustomerLicensePlateIndex", cancellationToken); + + // Cleanup old schema elements + await DropColumnIfExistsAsync(connection, "InventoryItems", "Quantity", cancellationToken); + await DropColumnIfExistsAsync(connection, "InventoryItems", "LastScannedAt", cancellationToken); + await DropColumnIfExistsAsync(connection, "InventoryItems", "Status", cancellationToken); + await DropColumnIfExistsAsync(connection, "InventoryItems", "RemovedAt", cancellationToken); + await ExecuteSqlSafeAsync(connection, "ALTER TABLE `InventoryItems` DROP INDEX `IX_InventoryItems_Status`;", "DropOldIndex", cancellationToken); + + // Migration: Add Type column to CatalogItems for Reifen/Felgen category distinction + await MigrateAddTypeToCatalogItemsAsync(connection, cancellationToken); + + // Migration: Add ItemCount column to TireStacks for variable inventory counts + await MigrateAddItemCountToTireStacksAsync(connection, cancellationToken); + + _logger.LogInformation("Datenbanktabellen wurden erstellt/überprüft."); + } + + private async Task MigrateTireSetsToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'TireSets';", + connection); + checkCmd.CommandTimeout = 30; + var tireSetsExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (tireSetsExists) + { + await using var countCmd = new MySqlCommand("SELECT COUNT(*) FROM `TireStacks`;", connection); + countCmd.CommandTimeout = 30; + var tireStacksCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken)); + + if (tireStacksCount == 0) + { + _logger.LogInformation("Migriere Daten von TireSets zu TireStacks..."); + + await ExecuteSqlSafeAsync(connection, """ + INSERT INTO `TireStacks` + (`Id`, `TicketNumber`, `CustomerId`, `StorageLocationId`, `CatalogItemId`, + `Status`, `TireSize`, `TireBrand`, `TireModel`, `Season`, `TreadDepth`, + `DotCode`, `WithRims`, `RimSize`, `HasTpms`, `Condition`, `Notes`, + `StoredAt`, `PickedUpAt`, `ExpectedPickupDate`, `StoredByUserId`, + `PickedUpByUserId`, `CreatedAt`, `UpdatedAt`) + SELECT + `Id`, `TicketNumber`, `CustomerId`, `StorageLocationId`, `CatalogItemId`, + `Status`, `TireSize`, `TireBrand`, `TireModel`, `Season`, `TreadDepth`, + `DotCode`, `WithRims`, `RimSize`, `HasTpms`, 1, `Notes`, + `StoredAt`, `PickedUpAt`, `ExpectedPickupDate`, `StoredByUserId`, + `PickedUpByUserId`, `CreatedAt`, `UpdatedAt` + FROM `TireSets`; + """, "MigrateTireSetsData", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireItems` DROP FOREIGN KEY `FK_TireItems_TireSets`;", + "DropTireItemsFK", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireItems` CHANGE COLUMN `TireSetId` `TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL;", + "RenameTireSetIdColumn", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireItems` ADD CONSTRAINT `FK_TireItems_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE;", + "AddTireItemsFK", cancellationToken); + + _logger.LogInformation("Migration von TireSets zu TireStacks abgeschlossen."); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration von TireSets zu TireStacks übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAddVehicleIdToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Check if VehicleId column exists + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'TireStacks' AND column_name = 'VehicleId';", + connection); + checkCmd.CommandTimeout = 30; + var vehicleIdExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!vehicleIdExists) + { + _logger.LogInformation("Füge VehicleId Spalte zu TireStacks hinzu..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireStacks` ADD COLUMN `VehicleId` CHAR(36) CHARACTER SET ascii NULL AFTER `CustomerId`;", + "AddVehicleIdColumn", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireStacks` ADD INDEX `IX_TireStacks_VehicleId` (`VehicleId`);", + "AddVehicleIdIndex", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireStacks` ADD CONSTRAINT `FK_TireStacks_Vehicles` FOREIGN KEY (`VehicleId`) REFERENCES `Vehicles` (`Id`) ON DELETE SET NULL;", + "AddVehicleIdFK", cancellationToken); + + _logger.LogInformation("VehicleId Spalte zu TireStacks hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für VehicleId übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateCustomerVehiclesToVehiclesTableAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Skip migration entirely if the legacy LicensePlate column no longer exists + await using var columnCheckCmd = new MySqlCommand(""" + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Customers' + AND COLUMN_NAME = 'LicensePlate'; + """, connection); + columnCheckCmd.CommandTimeout = 30; + var hasLegacyColumn = Convert.ToInt32(await columnCheckCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!hasLegacyColumn) + return; + + // Check if there are customers with LicensePlate but no vehicles + await using var checkCmd = new MySqlCommand(""" + SELECT COUNT(*) FROM `Customers` c + WHERE c.LicensePlate IS NOT NULL + AND c.LicensePlate != '' + AND NOT EXISTS ( + SELECT 1 FROM `Vehicles` v + WHERE v.CustomerId = c.Id AND v.LicensePlate = c.LicensePlate + ); + """, connection); + checkCmd.CommandTimeout = 30; + var customersToMigrate = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)); + + if (customersToMigrate > 0) + { + _logger.LogInformation("Migriere {Count} Kundenfahrzeuge in Vehicles-Tabelle...", customersToMigrate); + + // Insert vehicles from customer data + await ExecuteSqlSafeAsync(connection, """ + INSERT INTO `Vehicles` (`Id`, `CustomerId`, `LicensePlate`, `Make`, `Model`, `IsPrimary`, `IsActive`, `CreatedAt`) + SELECT + UUID(), + c.Id, + c.LicensePlate, + c.VehicleMake, + c.VehicleModel, + 1, + 1, + COALESCE(c.CreatedAt, UTC_TIMESTAMP()) + FROM `Customers` c + WHERE c.LicensePlate IS NOT NULL + AND c.LicensePlate != '' + AND NOT EXISTS ( + SELECT 1 FROM `Vehicles` v + WHERE v.CustomerId = c.Id AND v.LicensePlate = c.LicensePlate + ); + """, "MigrateCustomerVehicles", cancellationToken); + + // Update TireStacks to reference the new vehicles + await ExecuteSqlSafeAsync(connection, """ + UPDATE `TireStacks` ts + INNER JOIN `Vehicles` v ON v.CustomerId = ts.CustomerId AND v.IsPrimary = 1 + SET ts.VehicleId = v.Id + WHERE ts.CustomerId IS NOT NULL AND ts.VehicleId IS NULL; + """, "UpdateTireStackVehicleIds", cancellationToken); + + _logger.LogInformation("Kundenfahrzeuge erfolgreich migriert."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration der Kundenfahrzeuge übersprungen oder fehlgeschlagen."); + } + } + + /// + /// Adds the Position column for the Gebäude/Reihe/Seite-Ebene/Platz code format. + /// Existing locations use the old code semantics and are only test data, + /// so they are removed; the default locations are re-seeded afterwards in InitializeAsync. + /// + private async Task MigrateStorageLocationsPositionAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'StorageLocations' AND column_name = 'Position';", + connection); + checkCmd.CommandTimeout = 30; + var positionColumnExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (positionColumnExists) + return; + + _logger.LogInformation("Migriere StorageLocations auf Gebäude/Reihe/Seite-Ebene/Platz-Format..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD COLUMN `Position` INT NOT NULL DEFAULT 1 AFTER `Row`;", + "AddPositionColumn", cancellationToken); + + // Old codes/rows are incompatible with the new format (test data only) - remove them + await ExecuteSqlSafeAsync(connection, + "UPDATE `TireStacks` SET `StorageLocationId` = NULL WHERE `StorageLocationId` IS NOT NULL;", + "DetachTireStacksFromLocations", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "DELETE FROM `StorageLocations`;", + "ClearOldStorageLocations", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD UNIQUE INDEX `IX_StorageLocations_Area_Row_Position_Slot` (`Area`, `Row`, `Position`, `SlotNumber`);", + "AddPositionIndex", cancellationToken); + + _logger.LogInformation("StorageLocations Position-Migration abgeschlossen."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "StorageLocations Position-Migration übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateStorageLocationsSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Check if new columns exist + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'StorageLocations' AND column_name = 'Area';", + connection); + checkCmd.CommandTimeout = 30; + var areaColumnExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!areaColumnExists) + { + _logger.LogInformation("Migriere StorageLocations Schema zu ABCD-Format..."); + + // Add new columns + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD COLUMN `Area` INT NOT NULL DEFAULT 1;", + "AddAreaColumn", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD COLUMN `SlotNumber` INT NOT NULL DEFAULT 1;", + "AddSlotNumberColumn", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD COLUMN `IsOccupied` TINYINT(1) NOT NULL DEFAULT 0;", + "AddIsOccupiedColumn", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD COLUMN `TireStackId` CHAR(36) CHARACTER SET ascii NULL;", + "AddTireStackIdColumn", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD COLUMN `OccupiedSince` DATETIME(6) NULL;", + "AddOccupiedSinceColumn", cancellationToken); + + // Add index and foreign key for TireStackId + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD INDEX `IX_StorageLocations_TireStackId` (`TireStackId`);", + "AddTireStackIdIndex", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD INDEX `IX_StorageLocations_Area_Row` (`Area`, `Row`);", + "AddAreaRowIndex", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` ADD INDEX `IX_StorageLocations_IsOccupied` (`IsOccupied`);", + "AddIsOccupiedIndex", cancellationToken); + + // Modify Row column to INT + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `StorageLocations` MODIFY COLUMN `Row` INT NOT NULL DEFAULT 1;", + "ModifyRowColumn", cancellationToken); + + // Drop old columns that are no longer needed + await DropColumnIfExistsAsync(connection, "StorageLocations", "Section", cancellationToken); + await DropColumnIfExistsAsync(connection, "StorageLocations", "Capacity", cancellationToken); + await DropColumnIfExistsAsync(connection, "StorageLocations", "Name", cancellationToken); + await DropColumnIfExistsAsync(connection, "StorageLocations", "QrCode", cancellationToken); + + _logger.LogInformation("StorageLocations Schema-Migration abgeschlossen."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "StorageLocations Schema-Migration übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAddFilePathToKanbanTaskAttachmentsAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Check if FilePath column exists + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTaskAttachments' AND column_name = 'FilePath';", + connection); + checkCmd.CommandTimeout = 30; + var filePathExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!filePathExists) + { + _logger.LogInformation("Füge FilePath Spalte zu KanbanTaskAttachments hinzu..."); + + // Add FilePath column + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `KanbanTaskAttachments` ADD COLUMN `FilePath` VARCHAR(500) NULL AFTER `FileSize`;", + "AddFilePathColumn", cancellationToken); + + // Make FileContent nullable for external storage support + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `KanbanTaskAttachments` MODIFY COLUMN `FileContent` LONGBLOB NULL;", + "MakeFileContentNullable", cancellationToken); + + _logger.LogInformation("FilePath Spalte zu KanbanTaskAttachments hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für FilePath übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAddNotesTextToKanbanTasksAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Check if NotesText column exists + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTasks' AND column_name = 'NotesText';", + connection); + checkCmd.CommandTimeout = 30; + var notesTextExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!notesTextExists) + { + _logger.LogInformation("Füge NotesText Spalte zu KanbanTasks hinzu..."); + + // Add NotesText column + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `KanbanTasks` ADD COLUMN `NotesText` LONGTEXT NULL AFTER `ArchivedAt`;", + "AddNotesTextColumn", cancellationToken); + + _logger.LogInformation("NotesText Spalte zu KanbanTasks hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für NotesText übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAddIsActiveToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'TireStacks' AND column_name = 'IsActive';", + connection); + checkCmd.CommandTimeout = 30; + var isActiveExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!isActiveExists) + { + _logger.LogInformation("Füge IsActive Spalte zu TireStacks hinzu..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireStacks` ADD COLUMN `IsActive` TINYINT(1) NOT NULL DEFAULT 1;", + "AddIsActiveToTireStacks", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireStacks` ADD INDEX `IX_TireStacks_IsActive` (`IsActive`);", + "AddIsActiveIndex", cancellationToken); + + _logger.LogInformation("IsActive Spalte zu TireStacks hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für TireStacks.IsActive übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateVehiclesRemoveLicensePlateAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Check if TireSize column already exists (migration already done) + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Vehicles' AND column_name = 'TireSize';", + connection); + checkCmd.CommandTimeout = 30; + var tireSizeExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!tireSizeExists) + { + _logger.LogInformation("Migriere Vehicles-Tabelle: Entferne LicensePlate/Year, füge TireSize hinzu..."); + + // Add TireSize column + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Vehicles` ADD COLUMN `TireSize` VARCHAR(50) NOT NULL DEFAULT '';", + "AddTireSizeToVehicles", cancellationToken); + + // Make Make NOT NULL with default + await ExecuteSqlSafeAsync(connection, + "UPDATE `Vehicles` SET `Make` = COALESCE(`Make`, 'Unbekannt') WHERE `Make` IS NULL;", + "SetDefaultMake", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Vehicles` MODIFY COLUMN `Make` VARCHAR(100) NOT NULL;", + "MakeMakeNotNull", cancellationToken); + + // Drop LicensePlate index, then column + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Vehicles` DROP INDEX `IX_Vehicles_LicensePlate`;", + "DropVehicleLicensePlateIndex", cancellationToken); + + await DropColumnIfExistsAsync(connection, "Vehicles", "LicensePlate", cancellationToken); + await DropColumnIfExistsAsync(connection, "Vehicles", "Year", cancellationToken); + + // Add new index on Make/Model + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Vehicles` ADD INDEX `IX_Vehicles_Make_Model` (`Make`, `Model`);", + "AddVehicleMakeModelIndex", cancellationToken); + + _logger.LogInformation("Vehicles-Tabelle erfolgreich migriert."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für Vehicles (LicensePlate-Entfernung) übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAdd2FaColumnsToUsersAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Users` ADD COLUMN `TwoFactorEnabled` TINYINT(1) NOT NULL DEFAULT 0;", + "AddTwoFactorEnabled", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Users` ADD COLUMN `TwoFactorSecret` VARCHAR(256) NULL;", + "AddTwoFactorSecret", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `Users` ADD COLUMN `TwoFactorEnforced` TINYINT(1) NOT NULL DEFAULT 0;", + "AddTwoFactorEnforced", cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für 2FA-Spalten übersprungen oder fehlgeschlagen."); + } + } + + private async Task ExecuteSqlSafeAsync(MySqlConnection connection, string sql, string name, CancellationToken cancellationToken) + { + try + { + await using var cmd = new MySqlCommand(sql, connection); + cmd.CommandTimeout = 60; // 60 second timeout for DDL operations + await cmd.ExecuteNonQueryAsync(cancellationToken); + _logger.LogInformation("SQL ausgeführt: {Name}", name); + } + catch (MySqlException ex) when (ex.Number is 1091 or 1061 or 1826 or 121 or 1050 or 1025 or 1553 or 1146 or 1060 or 1068) + { + // 1091: Can't DROP - doesn't exist + // 1061: Duplicate key name + // 1826: Duplicate foreign key constraint name + // 121: Duplicate key on write or update + // 1050: Table already exists + // 1025: Error on rename (constraint doesn't exist) + // 1553: Cannot drop check constraint (doesn't exist) + // 1146: Table doesn't exist + // 1060: Duplicate column name + // 1068: Multiple primary key defined + _logger.LogDebug("SQL übersprungen (bereits vorhanden oder nicht nötig): {Name}", name); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SQL Fehler (ignoriert): {Name}", name); + } + } + + private async Task DropColumnIfExistsAsync(MySqlConnection connection, string table, string column, CancellationToken cancellationToken) + { + try + { + await using var cmd = new MySqlCommand($"ALTER TABLE `{table}` DROP COLUMN `{column}`;", connection); + cmd.CommandTimeout = 60; + await cmd.ExecuteNonQueryAsync(cancellationToken); + _logger.LogInformation("Spalte '{Column}' aus '{Table}' entfernt.", column, table); + } + catch + { + // Column doesn't exist - ignore + } + } + + private async Task EnsureDatabaseExistsAsync(CancellationToken cancellationToken) + { + var connectionString = _configuration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrWhiteSpace(connectionString)) return; + + var dbName = GetDatabaseName(connectionString); + if (string.IsNullOrWhiteSpace(dbName)) return; + + var serverConnStr = GetServerConnectionString(connectionString); + var server = GetServerFromConnectionString(connectionString); + var port = GetPortFromConnectionString(connectionString) ?? "3306"; + + _logger.LogInformation("Prüfe ob Datenbank '{Database}' auf Server '{Server}:{Port}' existiert...", + dbName, server, port); + + await using var connection = new MySqlConnection(serverConnStr); + await connection.OpenAsync(cancellationToken); + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"CREATE DATABASE IF NOT EXISTS `{dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; + cmd.CommandTimeout = 30; + await cmd.ExecuteNonQueryAsync(cancellationToken); + + _logger.LogInformation("Datenbank '{Database}' ist verfügbar.", dbName); + } + + private static string? GetPortFromConnectionString(string? connectionString) + { + if (string.IsNullOrEmpty(connectionString)) return null; + + foreach (var part in connectionString.Split(';')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0].Trim().Equals("Port", StringComparison.OrdinalIgnoreCase)) + return kv[1].Trim(); + } + return null; + } + + private static string? GetDatabaseName(string connectionString) + { + foreach (var part in connectionString.Split(';')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0].Trim().Equals("Database", StringComparison.OrdinalIgnoreCase)) + return kv[1].Trim(); + } + return null; + } + + private static string GetServerConnectionString(string connectionString) + { + var parts = connectionString.Split(';') + .Where(p => !p.Trim().StartsWith("Database=", StringComparison.OrdinalIgnoreCase)); + return string.Join(";", parts); + } + + private async Task MigrateAddTypeToCatalogItemsAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'CatalogItems' AND column_name = 'Type';", + connection); + checkCmd.CommandTimeout = 30; + var typeExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!typeExists) + { + _logger.LogInformation("Füge Type Spalte zu CatalogItems hinzu..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `CatalogItems` ADD COLUMN `Type` INT NOT NULL DEFAULT 0;", + "AddTypeToCatalogItems", cancellationToken); + + _logger.LogInformation("Type Spalte zu CatalogItems hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für CatalogItems.Type übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAddItemCountToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'TireStacks' AND column_name = 'ItemCount';", + connection); + checkCmd.CommandTimeout = 30; + var itemCountExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!itemCountExists) + { + _logger.LogInformation("Füge ItemCount Spalte zu TireStacks hinzu..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `TireStacks` ADD COLUMN `ItemCount` INT NOT NULL DEFAULT 4;", + "AddItemCountToTireStacks", cancellationToken); + + _logger.LogInformation("ItemCount Spalte zu TireStacks hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für TireStacks.ItemCount übersprungen oder fehlgeschlagen."); + } + } + + #region SQL Statements + + private const string CreateCatalogItemsTableSql = """ + CREATE TABLE IF NOT EXISTS `CatalogItems` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Name` VARCHAR(200) NOT NULL, + `PictureFileName` VARCHAR(500) NULL, + `TireSize` VARCHAR(50) NOT NULL DEFAULT '', + `Season` INT NOT NULL DEFAULT 0, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateInventoryItemsTableSql = """ + CREATE TABLE IF NOT EXISTS `InventoryItems` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `QrCode` VARCHAR(500) NOT NULL, + `Name` VARCHAR(200) NULL, + `Description` VARCHAR(500) NULL, + `CatalogItemId` CHAR(36) CHARACTER SET ascii NULL, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + INDEX `IX_InventoryItems_QrCode` (`QrCode`), + INDEX `IX_InventoryItems_CatalogItemId` (`CatalogItemId`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateUsersTableSql = """ + CREATE TABLE IF NOT EXISTS `Users` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Username` VARCHAR(100) NOT NULL, + `PasswordHash` VARCHAR(256) NOT NULL, + `DisplayName` VARCHAR(200) NULL, + `Role` INT NOT NULL DEFAULT 0, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + `LastLoginAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + UNIQUE INDEX `IX_Users_Username` (`Username`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateSiteSettingsTableSql = """ + CREATE TABLE IF NOT EXISTS `SiteSettings` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Key` VARCHAR(100) NOT NULL, + `Value` TEXT NOT NULL, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + UNIQUE INDEX `IX_SiteSettings_Key` (`Key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateCustomersTableSql = """ + CREATE TABLE IF NOT EXISTS `Customers` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `CustomerNumber` VARCHAR(20) NOT NULL, + `FirstName` VARCHAR(100) NOT NULL, + `LastName` VARCHAR(100) NOT NULL, + `Company` VARCHAR(200) NULL, + `Phone` VARCHAR(20) NULL, + `Email` VARCHAR(100) NULL, + `Notes` VARCHAR(500) NULL, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + UNIQUE INDEX `IX_Customers_CustomerNumber` (`CustomerNumber`), + INDEX `IX_Customers_Name` (`LastName`, `FirstName`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateVehiclesTableSql = """ + CREATE TABLE IF NOT EXISTS `Vehicles` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `CustomerId` CHAR(36) CHARACTER SET ascii NOT NULL, + `Make` VARCHAR(100) NOT NULL, + `Model` VARCHAR(100) NULL, + `TireSize` VARCHAR(50) NOT NULL DEFAULT '', + `Notes` VARCHAR(500) NULL, + `IsPrimary` TINYINT(1) NOT NULL DEFAULT 0, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + INDEX `IX_Vehicles_CustomerId` (`CustomerId`), + INDEX `IX_Vehicles_Make_Model` (`Make`, `Model`), + INDEX `IX_Vehicles_CustomerId_IsPrimary` (`CustomerId`, `IsPrimary`), + CONSTRAINT `FK_Vehicles_Customers` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + /// + /// StorageLocations table with ABCD code format: + /// - Code: 4-digit code (e.g., "2235") + /// - Area: 1=Werkstatt (3 Reihen), 2=Lager (5 Reihen) + /// - Row: Werkstatt 1-3, Lager 1-5 + /// - Position: 1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten + /// - SlotNumber: 1-9 (längs) + /// - Each location holds exactly one tire stack + /// + private const string CreateStorageLocationsTableSql = """ + CREATE TABLE IF NOT EXISTS `StorageLocations` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Code` VARCHAR(4) NOT NULL, + `Area` INT NOT NULL DEFAULT 1, + `Row` INT NOT NULL DEFAULT 1, + `Position` INT NOT NULL DEFAULT 1, + `SlotNumber` INT NOT NULL DEFAULT 1, + `IsOccupied` TINYINT(1) NOT NULL DEFAULT 0, + `TireStackId` CHAR(36) CHARACTER SET ascii NULL, + `OccupiedSince` DATETIME(6) NULL, + `Notes` VARCHAR(500) NULL, + `IsAvailable` TINYINT(1) NOT NULL DEFAULT 1, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + UNIQUE INDEX `IX_StorageLocations_Code` (`Code`), + INDEX `IX_StorageLocations_Area_Row` (`Area`, `Row`), + UNIQUE INDEX `IX_StorageLocations_Area_Row_Position_Slot` (`Area`, `Row`, `Position`, `SlotNumber`), + INDEX `IX_StorageLocations_IsOccupied` (`IsOccupied`), + INDEX `IX_StorageLocations_TireStackId` (`TireStackId`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateTireStacksTableSql = """ + CREATE TABLE IF NOT EXISTS `TireStacks` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TicketNumber` VARCHAR(50) NOT NULL, + `CustomerId` CHAR(36) CHARACTER SET ascii NULL, + `VehicleId` CHAR(36) CHARACTER SET ascii NULL, + `StorageLocationId` CHAR(36) CHARACTER SET ascii NULL, + `CatalogItemId` CHAR(36) CHARACTER SET ascii NULL, + `Status` INT NOT NULL DEFAULT 0, + `TireSize` VARCHAR(50) NULL, + `TireBrand` VARCHAR(100) NULL, + `TireModel` VARCHAR(100) NULL, + `Season` INT NOT NULL DEFAULT 0, + `TreadDepth` DECIMAL(4,1) NULL, + `DotCode` VARCHAR(20) NULL, + `WithRims` TINYINT(1) NOT NULL DEFAULT 1, + `RimSize` VARCHAR(20) NULL, + `HasTpms` TINYINT(1) NOT NULL DEFAULT 0, + `Condition` INT NOT NULL DEFAULT 1, + `Notes` VARCHAR(1000) NULL, + `StoredAt` DATETIME(6) NOT NULL, + `PickedUpAt` DATETIME(6) NULL, + `ExpectedPickupDate` DATETIME(6) NULL, + `StoredByUserId` CHAR(36) CHARACTER SET ascii NULL, + `PickedUpByUserId` CHAR(36) CHARACTER SET ascii NULL, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + UNIQUE INDEX `IX_TireStacks_TicketNumber` (`TicketNumber`), + INDEX `IX_TireStacks_Status` (`Status`), + INDEX `IX_TireStacks_CustomerId` (`CustomerId`), + INDEX `IX_TireStacks_VehicleId` (`VehicleId`), + INDEX `IX_TireStacks_StorageLocationId` (`StorageLocationId`), + CONSTRAINT `FK_TireStacks_Customers` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`) ON DELETE RESTRICT, + CONSTRAINT `FK_TireStacks_Vehicles` FOREIGN KEY (`VehicleId`) REFERENCES `Vehicles` (`Id`) ON DELETE SET NULL, + CONSTRAINT `FK_TireStacks_StorageLocations` FOREIGN KEY (`StorageLocationId`) REFERENCES `StorageLocations` (`Id`) ON DELETE SET NULL, + CONSTRAINT `FK_TireStacks_CatalogItems` FOREIGN KEY (`CatalogItemId`) REFERENCES `CatalogItems` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateTireItemsTableSql = """ + CREATE TABLE IF NOT EXISTS `TireItems` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL, + `Position` INT NOT NULL DEFAULT 0, + `TreadDepth` DECIMAL(4,1) NULL, + `Condition` INT NOT NULL DEFAULT 1, + `DamageNotes` VARCHAR(500) NULL, + `DotCode` VARCHAR(20) NULL, + `PhotoFileName` VARCHAR(500) NULL, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + INDEX `IX_TireItems_TireStackId` (`TireStackId`), + CONSTRAINT `FK_TireItems_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + #region Kanban SQL Statements + + private const string CreateKanbanColumnsTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanColumns` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Name` VARCHAR(100) NOT NULL, + `SortOrder` INT NOT NULL DEFAULT 0, + `Color` VARCHAR(7) NULL, + `Icon` VARCHAR(10) NULL, + `IsDefault` TINYINT(1) NOT NULL DEFAULT 0, + `IsCompletedColumn` TINYINT(1) NOT NULL DEFAULT 0, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanColumns_SortOrder` (`SortOrder`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanLabelsTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanLabels` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Name` VARCHAR(50) NOT NULL, + `Color` VARCHAR(7) NOT NULL DEFAULT '#6B7280', + `Description` VARCHAR(200) NULL, + `SortOrder` INT NOT NULL DEFAULT 0, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanLabels_SortOrder` (`SortOrder`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTasksTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTasks` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Title` VARCHAR(200) NOT NULL, + `Description` LONGTEXT NULL, + `ColumnId` CHAR(36) CHARACTER SET ascii NOT NULL, + `SortOrder` INT NOT NULL DEFAULT 0, + `DueDate` DATETIME(6) NULL, + `Priority` INT NOT NULL DEFAULT 1, + `EstimatedMinutes` INT NULL, + `CreatedByUserId` CHAR(36) CHARACTER SET ascii NULL, + `IsArchived` TINYINT(1) NOT NULL DEFAULT 0, + `CompletedAt` DATETIME(6) NULL, + `ArchivedAt` DATETIME(6) NULL, + `NotesText` LONGTEXT NULL, + `TireSizeNote` VARCHAR(100) NULL, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanTasks_ColumnId` (`ColumnId`), + INDEX `IX_KanbanTasks_DueDate` (`DueDate`), + INDEX `IX_KanbanTasks_IsArchived` (`IsArchived`), + INDEX `IX_KanbanTasks_ColumnId_SortOrder` (`ColumnId`, `SortOrder`), + CONSTRAINT `FK_KanbanTasks_KanbanColumns` FOREIGN KEY (`ColumnId`) REFERENCES `KanbanColumns` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTasks_Users` FOREIGN KEY (`CreatedByUserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskTodosTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskTodos` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `Text` VARCHAR(500) NOT NULL, + `IsCompleted` TINYINT(1) NOT NULL DEFAULT 0, + `SortOrder` INT NOT NULL DEFAULT 0, + `CreatedAt` DATETIME(6) NOT NULL, + `CompletedAt` DATETIME(6) NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanTaskTodos_TaskId` (`TaskId`), + CONSTRAINT `FK_KanbanTaskTodos_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskCommentsTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskComments` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `UserId` CHAR(36) CHARACTER SET ascii NULL, + `Content` LONGTEXT NOT NULL, + `CommentType` INT NOT NULL DEFAULT 0, + `CreatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanTaskComments_TaskId` (`TaskId`), + INDEX `IX_KanbanTaskComments_CreatedAt` (`CreatedAt`), + CONSTRAINT `FK_KanbanTaskComments_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskComments_Users` FOREIGN KEY (`UserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskNotesTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskNotes` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `Title` VARCHAR(100) NOT NULL DEFAULT 'Notizen', + `Content` LONGTEXT NOT NULL, + `SortOrder` INT NOT NULL DEFAULT 0, + `LastEditedByUserId` CHAR(36) CHARACTER SET ascii NULL, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanTaskNotes_TaskId` (`TaskId`), + CONSTRAINT `FK_KanbanTaskNotes_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskNotes_Users` FOREIGN KEY (`LastEditedByUserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskAttachmentsTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskAttachments` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `FileName` VARCHAR(255) NOT NULL, + `ContentType` VARCHAR(100) NOT NULL DEFAULT 'application/octet-stream', + `FileSize` BIGINT NOT NULL DEFAULT 0, + `FilePath` VARCHAR(500) NULL, + `FileContent` LONGBLOB NULL, + `UploadedByUserId` CHAR(36) CHARACTER SET ascii NULL, + `CreatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanTaskAttachments_TaskId` (`TaskId`), + CONSTRAINT `FK_KanbanTaskAttachments_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskAttachments_Users` FOREIGN KEY (`UploadedByUserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskTemplatesTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskTemplates` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `Name` VARCHAR(100) NOT NULL, + `Description` VARCHAR(500) NULL, + `TitlePattern` VARCHAR(200) NOT NULL DEFAULT '', + `DefaultDescription` LONGTEXT NULL, + `DefaultColumnId` CHAR(36) CHARACTER SET ascii NULL, + `DefaultPriority` INT NOT NULL DEFAULT 1, + `DefaultEstimatedMinutes` INT NULL, + `SortOrder` INT NOT NULL DEFAULT 0, + `IsActive` TINYINT(1) NOT NULL DEFAULT 1, + `CreatedAt` DATETIME(6) NOT NULL, + `UpdatedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`Id`), + INDEX `IX_KanbanTaskTemplates_SortOrder` (`SortOrder`), + CONSTRAINT `FK_KanbanTaskTemplates_KanbanColumns` FOREIGN KEY (`DefaultColumnId`) REFERENCES `KanbanColumns` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskTemplateTodosTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskTemplateTodos` ( + `Id` CHAR(36) CHARACTER SET ascii NOT NULL, + `TemplateId` CHAR(36) CHARACTER SET ascii NOT NULL, + `Text` VARCHAR(500) NOT NULL, + `SortOrder` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_KanbanTaskTemplateTodos_KanbanTaskTemplates` FOREIGN KEY (`TemplateId`) REFERENCES `KanbanTaskTemplates` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskLabelsTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskLabels` ( + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `LabelId` CHAR(36) CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`TaskId`, `LabelId`), + CONSTRAINT `FK_KanbanTaskLabels_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskLabels_KanbanLabels` FOREIGN KEY (`LabelId`) REFERENCES `KanbanLabels` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskTemplateLabelsTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskTemplateLabels` ( + `TemplateId` CHAR(36) CHARACTER SET ascii NOT NULL, + `LabelId` CHAR(36) CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`TemplateId`, `LabelId`), + CONSTRAINT `FK_KanbanTaskTemplateLabels_KanbanTaskTemplates` FOREIGN KEY (`TemplateId`) REFERENCES `KanbanTaskTemplates` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskTemplateLabels_KanbanLabels` FOREIGN KEY (`LabelId`) REFERENCES `KanbanLabels` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskAssigneesTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskAssignees` ( + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `UserId` CHAR(36) CHARACTER SET ascii NOT NULL, + `AssignedAt` DATETIME(6) NOT NULL, + PRIMARY KEY (`TaskId`, `UserId`), + CONSTRAINT `FK_KanbanTaskAssignees_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskAssignees_Users` FOREIGN KEY (`UserId`) REFERENCES `Users` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskCustomersTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskCustomers` ( + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `CustomerId` CHAR(36) CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`TaskId`, `CustomerId`), + CONSTRAINT `FK_KanbanTaskCustomers_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskCustomers_Customers` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private const string CreateKanbanTaskTireStacksTableSql = """ + CREATE TABLE IF NOT EXISTS `KanbanTaskTireStacks` ( + `TaskId` CHAR(36) CHARACTER SET ascii NOT NULL, + `TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`TaskId`, `TireStackId`), + CONSTRAINT `FK_KanbanTaskTireStacks_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_KanbanTaskTireStacks_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + + private async Task MigrateAddTireSizeNoteToKanbanTasksAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTasks' AND column_name = 'TireSizeNote';", + connection); + checkCmd.CommandTimeout = 30; + var tireSizeNoteExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!tireSizeNoteExists) + { + _logger.LogInformation("Füge TireSizeNote Spalte zu KanbanTasks hinzu..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `KanbanTasks` ADD COLUMN `TireSizeNote` VARCHAR(100) NULL AFTER `NotesText`;", + "AddTireSizeNoteColumn", cancellationToken); + + _logger.LogInformation("TireSizeNote Spalte zu KanbanTasks hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für TireSizeNote übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateRenameKanbanWaitColumnAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await ExecuteSqlSafeAsync(connection, + "UPDATE `KanbanColumns` SET `Name` = 'Warte auf Kunden', `Icon` = '\u23F3' WHERE `Name` = 'Warte auf wen anders';", + "RenameKanbanWaitColumn", cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fehler beim Umbenennen der Kanban-Spalte."); + } + } + + private async Task MigrateFixKanbanColumnIconsAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + // Fix icons that were corrupted due to encoding issues in original seed data + var iconFixes = new[] + { + (Name: "Backlog", Icon: "\U0001F4E5"), + (Name: "ToDo", Icon: "\U0001F4CB"), + (Name: "In Arbeit", Icon: "\U0001F504"), + (Name: "Warte auf Kunden", Icon: "\u23F3"), + (Name: "Erledigt", Icon: "\u2705") + }; + + foreach (var fix in iconFixes) + { + await using var cmd = new MySqlCommand( + "UPDATE `KanbanColumns` SET `Icon` = @icon WHERE `Name` = @name AND (`Icon` IS NULL OR `Icon` = '' OR `Icon` LIKE '%?%');", + connection); + cmd.CommandTimeout = 30; + cmd.Parameters.AddWithValue("@icon", fix.Icon); + cmd.Parameters.AddWithValue("@name", fix.Name); + var affected = await cmd.ExecuteNonQueryAsync(cancellationToken); + if (affected > 0) + { + _logger.LogInformation("Kanban-Spalte '{Name}' Icon korrigiert.", fix.Name); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für Kanban-Spalten-Icons übersprungen oder fehlgeschlagen."); + } + } + + private async Task MigrateAddVehicleIdToKanbanTaskCustomersAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTaskCustomers' AND column_name = 'VehicleId';", + connection); + checkCmd.CommandTimeout = 30; + var vehicleIdExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0; + + if (!vehicleIdExists) + { + _logger.LogInformation("Füge VehicleId Spalte zu KanbanTaskCustomers hinzu..."); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `KanbanTaskCustomers` ADD COLUMN `VehicleId` CHAR(36) CHARACTER SET ascii NULL;", + "AddVehicleIdToKanbanTaskCustomers", cancellationToken); + + await ExecuteSqlSafeAsync(connection, + "ALTER TABLE `KanbanTaskCustomers` ADD CONSTRAINT `FK_KanbanTaskCustomers_Vehicles` FOREIGN KEY (`VehicleId`) REFERENCES `Vehicles` (`Id`) ON DELETE SET NULL;", + "AddVehicleIdFK", cancellationToken); + + _logger.LogInformation("VehicleId Spalte zu KanbanTaskCustomers hinzugefügt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration für VehicleId in KanbanTaskCustomers übersprungen oder fehlgeschlagen."); + } + } + + private async Task InitializeDefaultKanbanColumnsAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var checkCmd = new MySqlCommand("SELECT COUNT(*) FROM `KanbanColumns`;", connection); + checkCmd.CommandTimeout = 30; + var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)); + + if (count == 0) + { + _logger.LogInformation("Erstelle Standard-Kanban-Spalten..."); + + var columns = new[] + { + (Name: "Backlog", Order: 0, Icon: "\U0001F4E5", IsDefault: true, IsCompleted: false), + (Name: "ToDo", Order: 1, Icon: "\U0001F4CB", IsDefault: false, IsCompleted: false), + (Name: "In Arbeit", Order: 2, Icon: "\U0001F504", IsDefault: false, IsCompleted: false), + (Name: "Warte auf Kunden", Order: 3, Icon: "\u23F3", IsDefault: false, IsCompleted: false), + (Name: "Erledigt", Order: 4, Icon: "\u2705", IsDefault: false, IsCompleted: true) + }; + + foreach (var col in columns) + { + var id = Guid.NewGuid().ToString(); + var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.ffffff"); + var sql = $""" + INSERT INTO `KanbanColumns` (`Id`, `Name`, `SortOrder`, `Icon`, `IsDefault`, `IsCompletedColumn`, `IsActive`, `CreatedAt`, `UpdatedAt`) + VALUES ('{id}', '{col.Name}', {col.Order}, '{col.Icon}', {(col.IsDefault ? 1 : 0)}, {(col.IsCompleted ? 1 : 0)}, 1, '{now}', '{now}'); + """; + await ExecuteSqlSafeAsync(connection, sql, $"InsertColumn_{col.Name}", cancellationToken); + } + + _logger.LogInformation("Standard-Kanban-Spalten erstellt."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fehler beim Erstellen der Standard-Kanban-Spalten."); + } + } + + #endregion + + #endregion +} diff --git a/Lagerbestand/Services/FileStorageService.cs b/Lagerbestand/Services/FileStorageService.cs new file mode 100644 index 0000000..202234c --- /dev/null +++ b/Lagerbestand/Services/FileStorageService.cs @@ -0,0 +1,254 @@ +using Lagerbestand.Models; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Lagerbestand.Services; + +/// +/// File storage service for disk/NFS-based file operations +/// +public class FileStorageService : IFileStorageService +{ + private readonly ISiteSettingsService _siteSettingsService; + private readonly ILogger _logger; + private const string SettingsKey = "FileStorage"; + + public FileStorageService(ISiteSettingsService siteSettingsService, ILogger logger) + { + _siteSettingsService = siteSettingsService; + _logger = logger; + } + + public async Task GetSettingsAsync() + { + try + { + var json = await _siteSettingsService.GetValueAsync(SettingsKey); + if (string.IsNullOrEmpty(json)) + { + return new FileStorageSettings(); + } + + return JsonSerializer.Deserialize(json) ?? new FileStorageSettings(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading file storage settings"); + return new FileStorageSettings(); + } + } + + public async Task SaveSettingsAsync(FileStorageSettings settings) + { + var json = JsonSerializer.Serialize(settings); + await _siteSettingsService.SetValueAsync(SettingsKey, json); + _logger.LogInformation("File storage settings saved. Path: {BasePath}, Enabled: {IsEnabled}", + settings.BasePath, settings.IsEnabled); + } + + public Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return Task.FromResult((false, (string?)"Der Pfad darf nicht leer sein.")); + } + + try + { + // Check if directory exists or can be created + if (!Directory.Exists(path)) + { + try + { + Directory.CreateDirectory(path); + _logger.LogInformation("Created storage directory: {Path}", path); + } + catch (Exception ex) + { + return Task.FromResult((false, (string?)$"Verzeichnis kann nicht erstellt werden: {ex.Message}")); + } + } + + // Test write access + var testFile = Path.Combine(path, $".write_test_{Guid.NewGuid():N}"); + try + { + File.WriteAllText(testFile, "test"); + File.Delete(testFile); + } + catch (Exception ex) + { + return Task.FromResult((false, (string?)$"Keine Schreibrechte: {ex.Message}")); + } + + return Task.FromResult((true, (string?)null)); + } + catch (Exception ex) + { + return Task.FromResult((false, (string?)$"Fehler: {ex.Message}")); + } + } + + public async Task SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments") + { + var settings = await GetSettingsAsync(); + + if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) + { + throw new InvalidOperationException("Dateispeicher ist nicht konfiguriert."); + } + + // Create unique filename to avoid collisions + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var safeFileName = SanitizeFileName(fileName); + var extension = Path.GetExtension(safeFileName); + var nameWithoutExt = Path.GetFileNameWithoutExtension(safeFileName); + var finalFileName = $"{nameWithoutExt}_{timestamp}_{uniqueId}{extension}"; + + // Create subdirectory path with year/month structure + var datePath = DateTime.UtcNow.ToString("yyyy/MM"); + var relativePath = Path.Combine(subDirectory, datePath, finalFileName); + var fullPath = Path.Combine(settings.BasePath, relativePath); + + // Ensure directory exists + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllBytesAsync(fullPath, content); + _logger.LogInformation("File saved: {RelativePath} ({Size} bytes)", relativePath, content.Length); + + return relativePath; + } + + public async Task ReadFileAsync(string relativePath) + { + var settings = await GetSettingsAsync(); + + if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) + { + return null; + } + + var fullPath = ResolveSafePath(settings.BasePath, relativePath); + + if (fullPath == null || !File.Exists(fullPath)) + { + _logger.LogWarning("File not found: {RelativePath}", relativePath); + return null; + } + + return await File.ReadAllBytesAsync(fullPath); + } + + public async Task DeleteFileAsync(string relativePath) + { + var settings = await GetSettingsAsync(); + + if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) + { + return false; + } + + var fullPath = ResolveSafePath(settings.BasePath, relativePath); + if (fullPath == null) + { + return false; + } + + if (!File.Exists(fullPath)) + { + return true; // Already deleted + } + + try + { + File.Delete(fullPath); + _logger.LogInformation("File deleted: {RelativePath}", relativePath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting file: {RelativePath}", relativePath); + return false; + } + } + + public async Task FileExistsAsync(string relativePath) + { + var settings = await GetSettingsAsync(); + + if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) + { + return false; + } + + var fullPath = ResolveSafePath(settings.BasePath, relativePath); + return await Task.FromResult(fullPath != null && File.Exists(fullPath)); + } + + public async Task GetFileSizeAsync(string relativePath) + { + var settings = await GetSettingsAsync(); + + if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) + { + return 0; + } + + var fullPath = ResolveSafePath(settings.BasePath, relativePath); + + if (fullPath == null || !File.Exists(fullPath)) + { + return 0; + } + + var fileInfo = new FileInfo(fullPath); + return await Task.FromResult(fileInfo.Length); + } + + /// + /// Resolves a relative path against the base path and blocks path traversal attempts. + /// Returns null if the resolved path escapes the base directory. + /// + private string? ResolveSafePath(string basePath, string relativePath) + { + try + { + var fullBasePath = Path.GetFullPath(basePath); + var fullPath = Path.GetFullPath(Path.Combine(fullBasePath, relativePath)); + + if (!fullPath.StartsWith(fullBasePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Path traversal attempt blocked: {RelativePath}", relativePath); + return null; + } + + return fullPath; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Invalid path rejected: {RelativePath}", relativePath); + return null; + } + } + + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); + + // Limit length + if (sanitized.Length > 200) + { + var ext = Path.GetExtension(sanitized); + var name = Path.GetFileNameWithoutExtension(sanitized); + sanitized = name[..(200 - ext.Length)] + ext; + } + + return sanitized; + } +} diff --git a/Lagerbestand/Services/IFileStorageService.cs b/Lagerbestand/Services/IFileStorageService.cs new file mode 100644 index 0000000..c0db158 --- /dev/null +++ b/Lagerbestand/Services/IFileStorageService.cs @@ -0,0 +1,49 @@ +using Lagerbestand.Models; + +namespace Lagerbestand.Services; + +/// +/// Service interface for file storage operations (disk/NFS based) +/// +public interface IFileStorageService +{ + /// + /// Gets the current storage settings + /// + Task GetSettingsAsync(); + + /// + /// Updates the storage settings + /// + Task SaveSettingsAsync(FileStorageSettings settings); + + /// + /// Validates if the storage path is accessible and writable + /// + Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path); + + /// + /// Saves a file to storage and returns the relative path + /// + Task SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments"); + + /// + /// Reads a file from storage + /// + Task ReadFileAsync(string relativePath); + + /// + /// Deletes a file from storage + /// + Task DeleteFileAsync(string relativePath); + + /// + /// Checks if a file exists in storage + /// + Task FileExistsAsync(string relativePath); + + /// + /// Gets the file size in bytes + /// + Task GetFileSizeAsync(string relativePath); +} diff --git a/Lagerbestand/Services/IKanbanService.cs b/Lagerbestand/Services/IKanbanService.cs new file mode 100644 index 0000000..982acc3 --- /dev/null +++ b/Lagerbestand/Services/IKanbanService.cs @@ -0,0 +1,98 @@ +using Lagerbestand.Models; + +namespace Lagerbestand.Services; + +public interface IKanbanService +{ + // Columns + Task> GetColumnsAsync(); + Task GetColumnByIdAsync(Guid id); + Task CreateColumnAsync(KanbanColumn column); + Task UpdateColumnAsync(KanbanColumn column); + Task DeleteColumnAsync(Guid id); + Task ReorderColumnsAsync(List columnIds); + + // Labels + Task> GetLabelsAsync(); + Task GetLabelByIdAsync(Guid id); + Task CreateLabelAsync(KanbanLabel label); + Task UpdateLabelAsync(KanbanLabel label); + Task DeleteLabelAsync(Guid id); + + // Tasks + Task> GetTasksAsync(bool includeArchived = false); + Task> GetTasksByColumnAsync(Guid columnId); + Task GetTaskByIdAsync(Guid id); + Task CreateTaskAsync(KanbanTask task, Guid? userId = null); + Task CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null); + Task UpdateTaskAsync(KanbanTask task, Guid? userId = null); + Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null); + Task DeleteTaskAsync(Guid id); + Task ArchiveTaskAsync(Guid id); + Task UnarchiveTaskAsync(Guid id); + Task> GetRecentlyArchivedTasksAsync(int days = 7); + Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30); + + // Task Todos + Task AddTodoAsync(Guid taskId, string text); + Task UpdateTodoAsync(KanbanTaskTodo todo); + Task ToggleTodoAsync(Guid todoId); + Task DeleteTodoAsync(Guid todoId); + Task ReorderTodosAsync(Guid taskId, List todoIds); + + // Task Comments/Chat + Task> GetCommentsAsync(Guid taskId); + Task AddCommentAsync(Guid taskId, string content, Guid? userId); + Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null); + + // Task Notes + Task> GetNotesAsync(Guid taskId); + Task AddNoteAsync(Guid taskId, string title, string content); + Task UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null); + Task DeleteNoteAsync(Guid noteId); + + // Task Attachments + Task> GetAttachmentsAsync(Guid taskId); + Task GetAttachmentByIdAsync(Guid id); + Task AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId); + Task GetAttachmentContentAsync(Guid attachmentId); + Task DeleteAttachmentAsync(Guid attachmentId); + + // Task Labels + Task AddLabelToTaskAsync(Guid taskId, Guid labelId); + Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId); + Task SetTaskLabelsAsync(Guid taskId, List labelIds); + + // Task Assignees + Task AddAssigneeAsync(Guid taskId, Guid userId); + Task RemoveAssigneeAsync(Guid taskId, Guid userId); + Task SetAssigneesAsync(Guid taskId, List userIds, Guid? changedByUserId = null); + + // Task Customers + Task AddCustomerToTaskAsync(Guid taskId, Guid customerId); + Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId); + Task SetTaskCustomersAsync(Guid taskId, Dictionary customerVehicleMap); + + // Task TireStacks + Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId); + Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId); + Task SetTaskTireStacksAsync(Guid taskId, List tireStackIds); + + // Templates + Task> GetTemplatesAsync(); + Task GetTemplateByIdAsync(Guid id); + Task CreateTemplateAsync(KanbanTaskTemplate template); + Task UpdateTemplateAsync(KanbanTaskTemplate template); + Task DeleteTemplateAsync(Guid id); + + // Template Todos + Task AddTemplateTodoAsync(Guid templateId, string text); + Task DeleteTemplateTodoAsync(Guid todoId); + Task SyncTemplateTodosAsync(Guid templateId, List todos); + + // Template Labels + Task SetTemplateLabelsAsync(Guid templateId, List labelIds); + + // Statistics/Dashboard + Task GetStatisticsAsync(); +} diff --git a/Lagerbestand/Services/InventoryService.cs b/Lagerbestand/Services/InventoryService.cs new file mode 100644 index 0000000..b06aa7a --- /dev/null +++ b/Lagerbestand/Services/InventoryService.cs @@ -0,0 +1,189 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +public interface IInventoryService +{ + Task> GetAllAsync(); + Task GetByIdAsync(Guid id); + Task GetByQrCodeAsync(string qrCode); + Task ExistsAsync(string qrCode); + Task AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null); + Task> AddItemsAsync(List qrCodes, Guid? catalogItemId = null); + Task RemoveItemAsync(string qrCode); + Task RemoveItemsAsync(List qrCodes); + Task UpdateCategoryAsync(Guid id, Guid? catalogItemId); + Task UpdateCategoryForItemsAsync(List itemIds, Guid? catalogItemId); + Task DeleteAsync(Guid id); +} + +public class InventoryService : IInventoryService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public InventoryService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task> GetAllAsync() + { + return await _context.InventoryItems + .AsNoTracking() + .Include(x => x.CatalogItem) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.InventoryItems + .Include(x => x.CatalogItem) + .FirstOrDefaultAsync(x => x.Id == id); + } + + public async Task GetByQrCodeAsync(string qrCode) + { + return await _context.InventoryItems + .Include(x => x.CatalogItem) + .FirstOrDefaultAsync(x => x.QrCode == qrCode); + } + + public async Task ExistsAsync(string qrCode) + { + return await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode); + } + + public async Task AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null) + { + if (await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode)) + throw new InvalidOperationException($"Artikel mit QR-Code '{qrCode}' ist bereits im Bestand."); + + var item = new InventoryItem + { + Id = Guid.NewGuid(), + QrCode = qrCode, + CatalogItemId = catalogItemId, + Name = name, + CreatedAt = DateTime.UtcNow + }; + + _context.InventoryItems.Add(item); + await _context.SaveChangesAsync(); + + return await GetByIdAsync(item.Id) ?? item; + } + + public async Task> AddItemsAsync(List qrCodes, Guid? catalogItemId = null) + { + var addedItems = new List(); + var now = DateTime.UtcNow; + var distinctCodes = qrCodes.Distinct().ToList(); + + var existingCodes = await _context.InventoryItems + .Where(x => distinctCodes.Contains(x.QrCode)) + .Select(x => x.QrCode) + .ToListAsync(); + var existingSet = existingCodes.ToHashSet(); + + foreach (var qrCode in distinctCodes) + { + if (existingSet.Contains(qrCode)) + continue; + + var item = new InventoryItem + { + Id = Guid.NewGuid(), + QrCode = qrCode, + CatalogItemId = catalogItemId, + CreatedAt = now + }; + + _context.InventoryItems.Add(item); + addedItems.Add(item); + } + + if (addedItems.Count > 0) + await _context.SaveChangesAsync(); + + return addedItems; + } + + public async Task RemoveItemAsync(string qrCode) + { + var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode); + if (item == null) + return false; + + _context.InventoryItems.Remove(item); + await _context.SaveChangesAsync(); + return true; + } + + public async Task RemoveItemsAsync(List qrCodes) + { + var count = 0; + foreach (var qrCode in qrCodes.Distinct()) + { + var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode); + if (item != null) + { + _context.InventoryItems.Remove(item); + count++; + } + } + + if (count > 0) + await _context.SaveChangesAsync(); + + return count; + } + + public async Task UpdateCategoryAsync(Guid id, Guid? catalogItemId) + { + var item = await _context.InventoryItems.FindAsync(id) + ?? throw new InvalidOperationException("Artikel nicht gefunden"); + + item.CatalogItemId = catalogItemId; + item.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + return item; + } + + public async Task UpdateCategoryForItemsAsync(List itemIds, Guid? catalogItemId) + { + var now = DateTime.UtcNow; + var count = 0; + + foreach (var id in itemIds) + { + var item = await _context.InventoryItems.FindAsync(id); + if (item != null) + { + item.CatalogItemId = catalogItemId; + item.UpdatedAt = now; + count++; + } + } + + if (count > 0) + await _context.SaveChangesAsync(); + + return count; + } + + public async Task DeleteAsync(Guid id) + { + var item = await _context.InventoryItems.FindAsync(id); + if (item != null) + { + _context.InventoryItems.Remove(item); + await _context.SaveChangesAsync(); + } + } +} diff --git a/Lagerbestand/Services/KanbanService.cs b/Lagerbestand/Services/KanbanService.cs new file mode 100644 index 0000000..538c23d --- /dev/null +++ b/Lagerbestand/Services/KanbanService.cs @@ -0,0 +1,1244 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +public class KanbanService : IKanbanService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + private readonly IFileStorageService _fileStorageService; + + public KanbanService(AppDbContext context, ILogger logger, IFileStorageService fileStorageService) + { + _context = context; + _logger = logger; + _fileStorageService = fileStorageService; + } + + #region Columns + + public async Task> GetColumnsAsync() + { + return await _context.KanbanColumns + .Where(c => c.IsActive) + .OrderBy(c => c.SortOrder) + .ToListAsync(); + } + + public async Task GetColumnByIdAsync(Guid id) + { + return await _context.KanbanColumns.FindAsync(id); + } + + public async Task CreateColumnAsync(KanbanColumn column) + { + column.Id = Guid.NewGuid(); + column.CreatedAt = DateTime.UtcNow; + column.UpdatedAt = DateTime.UtcNow; + + var maxOrder = await _context.KanbanColumns.MaxAsync(c => (int?)c.SortOrder) ?? -1; + column.SortOrder = maxOrder + 1; + + _context.KanbanColumns.Add(column); + await _context.SaveChangesAsync(); + return column; + } + + public async Task UpdateColumnAsync(KanbanColumn column) + { + var existing = await _context.KanbanColumns.FindAsync(column.Id) + ?? throw new InvalidOperationException("Spalte nicht gefunden."); + + existing.Name = column.Name; + existing.Color = column.Color; + existing.Icon = column.Icon; + existing.IsDefault = column.IsDefault; + existing.IsCompletedColumn = column.IsCompletedColumn; + existing.UpdatedAt = DateTime.UtcNow; + + // If this is set as default, unset others + if (column.IsDefault) + { + await _context.KanbanColumns + .Where(c => c.Id != column.Id && c.IsDefault) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.IsDefault, false)); + } + + await _context.SaveChangesAsync(); + return existing; + } + + public async Task DeleteColumnAsync(Guid id) + { + var column = await _context.KanbanColumns.FindAsync(id) + ?? throw new InvalidOperationException("Spalte nicht gefunden."); + + // Check if there are tasks in this column + var hasActiveTasks = await _context.KanbanTasks + .AnyAsync(t => t.ColumnId == id && !t.IsArchived); + + if (hasActiveTasks) + { + throw new InvalidOperationException("Spalte enthält noch aktive Aufgaben."); + } + + column.IsActive = false; + column.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + + public async Task ReorderColumnsAsync(List columnIds) + { + for (int i = 0; i < columnIds.Count; i++) + { + await _context.KanbanColumns + .Where(c => c.Id == columnIds[i]) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.SortOrder, i)); + } + } + + #endregion + + #region Labels + + public async Task> GetLabelsAsync() + { + return await _context.KanbanLabels + .Where(l => l.IsActive) + .OrderBy(l => l.SortOrder) + .ToListAsync(); + } + + public async Task GetLabelByIdAsync(Guid id) + { + return await _context.KanbanLabels.FindAsync(id); + } + + public async Task CreateLabelAsync(KanbanLabel label) + { + label.Id = Guid.NewGuid(); + label.CreatedAt = DateTime.UtcNow; + + var maxOrder = await _context.KanbanLabels.MaxAsync(l => (int?)l.SortOrder) ?? -1; + label.SortOrder = maxOrder + 1; + + _context.KanbanLabels.Add(label); + await _context.SaveChangesAsync(); + return label; + } + + public async Task UpdateLabelAsync(KanbanLabel label) + { + var existing = await _context.KanbanLabels.FindAsync(label.Id) + ?? throw new InvalidOperationException("Label nicht gefunden."); + + existing.Name = label.Name; + existing.Color = label.Color; + existing.Description = label.Description; + + await _context.SaveChangesAsync(); + return existing; + } + + public async Task DeleteLabelAsync(Guid id) + { + var label = await _context.KanbanLabels.FindAsync(id) + ?? throw new InvalidOperationException("Label nicht gefunden."); + + label.IsActive = false; + await _context.SaveChangesAsync(); + } + + #endregion + + #region Tasks + + public async Task> GetTasksAsync(bool includeArchived = false) + { + var query = _context.KanbanTasks + .AsSplitQuery() // Prevents cartesian explosion with multiple includes + .Include(t => t.Column) + .Include(t => t.TaskLabels).ThenInclude(tl => tl.Label) + .Include(t => t.Assignees).ThenInclude(a => a.User) + .Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer) + .Include(t => t.Todos) + .AsQueryable(); + + if (!includeArchived) + { + query = query.Where(t => !t.IsArchived); + } + + return await query + .OrderBy(t => t.Column!.SortOrder) + .ThenBy(t => t.SortOrder) + .ToListAsync(); + } + + public async Task> GetTasksByColumnAsync(Guid columnId) + { + return await _context.KanbanTasks + .AsSplitQuery() + .Include(t => t.TaskLabels).ThenInclude(tl => tl.Label) + .Include(t => t.Assignees).ThenInclude(a => a.User) + .Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer) + .Include(t => t.Todos) + .Where(t => t.ColumnId == columnId && !t.IsArchived) + .OrderBy(t => t.SortOrder) + .ToListAsync(); + } + + public async Task GetTaskByIdAsync(Guid id) + { + return await _context.KanbanTasks + .AsSplitQuery() // Prevents cartesian explosion with multiple includes + .Include(t => t.Column) + .Include(t => t.CreatedByUser) + .Include(t => t.TaskLabels).ThenInclude(tl => tl.Label) + .Include(t => t.Assignees).ThenInclude(a => a.User) + .Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer) + .ThenInclude(c => c.Vehicles) + .Include(t => t.TaskCustomers).ThenInclude(tc => tc.Vehicle) + .Include(t => t.TaskTireStacks).ThenInclude(ts => ts.TireStack) + .ThenInclude(s => s!.Vehicle) + .Include(t => t.Todos.OrderBy(todo => todo.SortOrder)) + .Include(t => t.Comments.OrderByDescending(c => c.CreatedAt)) + .ThenInclude(c => c.User) + .Include(t => t.Notes.OrderBy(n => n.SortOrder)) + .Include(t => t.Attachments.OrderByDescending(a => a.CreatedAt)) + .ThenInclude(a => a.UploadedByUser) + .FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task CreateTaskAsync(KanbanTask task, Guid? userId = null) + { + task.Id = Guid.NewGuid(); + task.CreatedAt = DateTime.UtcNow; + task.UpdatedAt = DateTime.UtcNow; + task.CreatedByUserId = userId; + + // If no column specified, use default + if (task.ColumnId == Guid.Empty) + { + var defaultColumn = await _context.KanbanColumns + .FirstOrDefaultAsync(c => c.IsDefault && c.IsActive) + ?? await _context.KanbanColumns.FirstOrDefaultAsync(c => c.IsActive); + + if (defaultColumn == null) + throw new InvalidOperationException("Keine Kanban-Spalte vorhanden."); + + task.ColumnId = defaultColumn.Id; + } + + // Set sort order to end of column + var maxOrder = await _context.KanbanTasks + .Where(t => t.ColumnId == task.ColumnId && !t.IsArchived) + .MaxAsync(t => (int?)t.SortOrder) ?? -1; + task.SortOrder = maxOrder + 1; + + _context.KanbanTasks.Add(task); + await _context.SaveChangesAsync(); + + // Add activity + await AddActivityAsync(task.Id, "Aufgabe erstellt", userId); + + return task; + } + + public async Task CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null) + { + var template = await _context.KanbanTaskTemplates + .Include(t => t.Todos) + .Include(t => t.TemplateLabels) + .FirstOrDefaultAsync(t => t.Id == templateId && t.IsActive) + ?? throw new InvalidOperationException("Vorlage nicht gefunden."); + + // Load customer for placeholder resolution + Customer? customer = null; + if (customerId.HasValue) + { + customer = await _context.Customers + .Include(c => c.Vehicles.Where(v => v.IsActive)) + .FirstOrDefaultAsync(c => c.Id == customerId.Value); + } + + var resolvedTitle = string.IsNullOrEmpty(title) ? template.TitlePattern : title; + var resolvedDescription = template.DefaultDescription; + + if (customer != null) + { + resolvedTitle = ResolvePlaceholders(resolvedTitle, customer); + resolvedDescription = ResolvePlaceholders(resolvedDescription, customer); + } + + var task = new KanbanTask + { + Title = resolvedTitle, + Description = resolvedDescription, + ColumnId = template.DefaultColumnId ?? Guid.Empty, + Priority = template.DefaultPriority, + EstimatedMinutes = template.DefaultEstimatedMinutes + }; + + var createdTask = await CreateTaskAsync(task, userId); + + // Add todos from template (with placeholder resolution) + foreach (var templateTodo in template.Todos.OrderBy(t => t.SortOrder)) + { + var todoText = customer != null ? ResolvePlaceholders(templateTodo.Text, customer) : templateTodo.Text; + await AddTodoAsync(createdTask.Id, todoText); + } + + // Add labels from template + var labelIds = template.TemplateLabels.Select(tl => tl.LabelId).ToList(); + if (labelIds.Any()) + { + await SetTaskLabelsAsync(createdTask.Id, labelIds); + } + + // Add customer if provided + if (customerId.HasValue) + { + await AddCustomerToTaskAsync(createdTask.Id, customerId.Value); + } + + return await GetTaskByIdAsync(createdTask.Id) ?? createdTask; + } + + private static string ResolvePlaceholders(string? text, Customer customer) + { + if (string.IsNullOrEmpty(text)) return text ?? string.Empty; + + var vehicle = customer.Vehicles + .OrderByDescending(v => v.IsPrimary) + .ThenBy(v => v.CreatedAt) + .FirstOrDefault(); + + var result = text; + result = result.Replace("{Kunde}", customer.FullName); + result = result.Replace("{Vorname}", customer.FirstName ?? ""); + result = result.Replace("{Nachname}", customer.LastName); + result = result.Replace("{Firma}", customer.Company ?? ""); + result = result.Replace("{Kundennummer}", customer.CustomerNumber); + result = result.Replace("{Telefon}", customer.Phone ?? ""); + result = result.Replace("{Email}", customer.Email ?? ""); + result = result.Replace("{Fahrzeug}", vehicle?.DisplayName ?? ""); + result = result.Replace("{Reifengröße}", vehicle?.TireSize ?? ""); + result = result.Replace("{Marke}", vehicle?.Make ?? ""); + result = result.Replace("{Modell}", vehicle?.Model ?? ""); + result = result.Replace("{Datum}", DateTime.Now.ToString("dd.MM.yyyy")); + result = result.Replace("{Jahr}", DateTime.Now.Year.ToString()); + + return result; + } + + public async Task UpdateTaskAsync(KanbanTask task, Guid? userId = null) + { + var existing = await _context.KanbanTasks.FindAsync(task.Id) + ?? throw new InvalidOperationException("Aufgabe nicht gefunden."); + + var changes = new List(); + + if (existing.Title != task.Title) + changes.Add($"Titel geändert zu '{task.Title}'"); + if (existing.Priority != task.Priority) + changes.Add($"Priorität geändert zu {task.PriorityText}"); + if (existing.DueDate != task.DueDate) + changes.Add(task.DueDate.HasValue + ? $"Fälligkeitsdatum gesetzt auf {task.DueDate.Value:dd.MM.yyyy}" + : "Fälligkeitsdatum entfernt"); + + existing.Title = task.Title; + existing.Description = task.Description; + existing.DueDate = task.DueDate; + existing.Priority = task.Priority; + existing.EstimatedMinutes = task.EstimatedMinutes; + existing.TireSizeNote = task.TireSizeNote; + existing.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + // Log activity + if (changes.Any()) + { + await AddActivityAsync(task.Id, string.Join(", ", changes), userId); + } + + return existing; + } + + public async Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null) + { + var task = await _context.KanbanTasks + .Include(t => t.Column) + .FirstOrDefaultAsync(t => t.Id == taskId) + ?? throw new InvalidOperationException("Aufgabe nicht gefunden."); + + var newColumn = await _context.KanbanColumns.FindAsync(newColumnId) + ?? throw new InvalidOperationException("Spalte nicht gefunden."); + + var oldColumnName = task.Column?.Name ?? "Unbekannt"; + var columnChanged = task.ColumnId != newColumnId; + + task.ColumnId = newColumnId; + task.SortOrder = newSortOrder; + task.UpdatedAt = DateTime.UtcNow; + + // Track completion + if (newColumn.IsCompletedColumn && !task.CompletedAt.HasValue) + { + task.CompletedAt = DateTime.UtcNow; + } + else if (!newColumn.IsCompletedColumn && task.CompletedAt.HasValue) + { + task.CompletedAt = null; + } + + // Reorder other tasks in the new column + var tasksInColumn = await _context.KanbanTasks + .Where(t => t.ColumnId == newColumnId && t.Id != taskId && !t.IsArchived) + .OrderBy(t => t.SortOrder) + .ToListAsync(); + + int order = 0; + foreach (var t in tasksInColumn) + { + if (order == newSortOrder) order++; + t.SortOrder = order++; + } + + await _context.SaveChangesAsync(); + + if (columnChanged) + { + await AddActivityAsync(taskId, $"Verschoben von '{oldColumnName}' nach '{newColumn.Name}'", userId); + } + } + + public async Task DeleteTaskAsync(Guid id) + { + var task = await _context.KanbanTasks.FindAsync(id) + ?? throw new InvalidOperationException("Aufgabe nicht gefunden."); + + task.IsArchived = true; + task.ArchivedAt = DateTime.UtcNow; + task.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + } + + public async Task ArchiveTaskAsync(Guid id) + { + var task = await _context.KanbanTasks.FindAsync(id) + ?? throw new InvalidOperationException("Aufgabe nicht gefunden."); + + task.IsArchived = true; + task.ArchivedAt = DateTime.UtcNow; + task.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + } + + public async Task UnarchiveTaskAsync(Guid id) + { + var task = await _context.KanbanTasks + .Include(t => t.Column) + .FirstOrDefaultAsync(t => t.Id == id) + ?? throw new InvalidOperationException("Aufgabe nicht gefunden."); + + task.IsArchived = false; + task.ArchivedAt = null; + task.UpdatedAt = DateTime.UtcNow; + + // If the task was in a completed column, keep it there; otherwise move to default + if (task.Column == null || !task.Column.IsActive) + { + var defaultColumn = await _context.KanbanColumns + .FirstOrDefaultAsync(c => c.IsDefault && c.IsActive) + ?? await _context.KanbanColumns.FirstOrDefaultAsync(c => c.IsActive); + + if (defaultColumn != null) + task.ColumnId = defaultColumn.Id; + } + + await _context.SaveChangesAsync(); + await AddActivityAsync(id, "Aufgabe wiederhergestellt", null); + } + + public async Task> GetRecentlyArchivedTasksAsync(int days = 7) + { + var cutoffDate = DateTime.UtcNow.AddDays(-days); + + return await _context.KanbanTasks + .AsSplitQuery() + .Include(t => t.Column) + .Include(t => t.TaskLabels).ThenInclude(tl => tl.Label) + .Include(t => t.Assignees).ThenInclude(a => a.User) + .Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer) + .Include(t => t.Todos) + .Where(t => t.IsArchived && t.ArchivedAt.HasValue && t.ArchivedAt.Value >= cutoffDate) + .OrderByDescending(t => t.ArchivedAt) + .ToListAsync(); + } + + public async Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30) + { + var cutoffDate = DateTime.UtcNow.AddDays(-daysAfterCompletion); + + await _context.KanbanTasks + .Where(t => t.CompletedAt.HasValue + && t.CompletedAt.Value < cutoffDate + && !t.IsArchived) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.IsArchived, true) + .SetProperty(t => t.ArchivedAt, DateTime.UtcNow) + .SetProperty(t => t.UpdatedAt, DateTime.UtcNow)); + + _logger.LogInformation("Alte erledigte Aufgaben archiviert (älter als {Days} Tage).", daysAfterCompletion); + } + + #endregion + + #region Task Todos + + public async Task AddTodoAsync(Guid taskId, string text) + { + var maxOrder = await _context.KanbanTaskTodos + .Where(t => t.TaskId == taskId) + .MaxAsync(t => (int?)t.SortOrder) ?? -1; + + var todo = new KanbanTaskTodo + { + Id = Guid.NewGuid(), + TaskId = taskId, + Text = text, + SortOrder = maxOrder + 1, + CreatedAt = DateTime.UtcNow + }; + + _context.KanbanTaskTodos.Add(todo); + await _context.SaveChangesAsync(); + return todo; + } + + public async Task UpdateTodoAsync(KanbanTaskTodo todo) + { + var existing = await _context.KanbanTaskTodos.FindAsync(todo.Id) + ?? throw new InvalidOperationException("Todo nicht gefunden."); + + existing.Text = todo.Text; + await _context.SaveChangesAsync(); + return existing; + } + + public async Task ToggleTodoAsync(Guid todoId) + { + var todo = await _context.KanbanTaskTodos.FindAsync(todoId) + ?? throw new InvalidOperationException("Todo nicht gefunden."); + + todo.IsCompleted = !todo.IsCompleted; + todo.CompletedAt = todo.IsCompleted ? DateTime.UtcNow : null; + + await _context.SaveChangesAsync(); + } + + public async Task DeleteTodoAsync(Guid todoId) + { + var todo = await _context.KanbanTaskTodos.FindAsync(todoId); + if (todo != null) + { + _context.KanbanTaskTodos.Remove(todo); + await _context.SaveChangesAsync(); + } + } + + public async Task ReorderTodosAsync(Guid taskId, List todoIds) + { + for (int i = 0; i < todoIds.Count; i++) + { + await _context.KanbanTaskTodos + .Where(t => t.Id == todoIds[i] && t.TaskId == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.SortOrder, i)); + } + } + + #endregion + + #region Comments/Chat + + public async Task> GetCommentsAsync(Guid taskId) + { + return await _context.KanbanTaskComments + .Include(c => c.User) + .Where(c => c.TaskId == taskId) + .OrderByDescending(c => c.CreatedAt) + .ToListAsync(); + } + + public async Task AddCommentAsync(Guid taskId, string content, Guid? userId) + { + var comment = new KanbanTaskComment + { + Id = Guid.NewGuid(), + TaskId = taskId, + UserId = userId, + Content = content, + CommentType = KanbanCommentType.Chat, + CreatedAt = DateTime.UtcNow + }; + + _context.KanbanTaskComments.Add(comment); + await _context.SaveChangesAsync(); + + return await _context.KanbanTaskComments + .Include(c => c.User) + .FirstAsync(c => c.Id == comment.Id); + } + + public async Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null) + { + var comment = new KanbanTaskComment + { + Id = Guid.NewGuid(), + TaskId = taskId, + UserId = userId, + Content = activity, + CommentType = KanbanCommentType.Activity, + CreatedAt = DateTime.UtcNow + }; + + _context.KanbanTaskComments.Add(comment); + await _context.SaveChangesAsync(); + } + + #endregion + + #region Notes + + public async Task> GetNotesAsync(Guid taskId) + { + return await _context.KanbanTaskNotes + .Include(n => n.LastEditedByUser) + .Where(n => n.TaskId == taskId) + .OrderBy(n => n.SortOrder) + .ToListAsync(); + } + + public async Task AddNoteAsync(Guid taskId, string title, string content) + { + var maxOrder = await _context.KanbanTaskNotes + .Where(n => n.TaskId == taskId) + .MaxAsync(n => (int?)n.SortOrder) ?? -1; + + var note = new KanbanTaskNote + { + Id = Guid.NewGuid(), + TaskId = taskId, + Title = title, + Content = content, + SortOrder = maxOrder + 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _context.KanbanTaskNotes.Add(note); + await _context.SaveChangesAsync(); + return note; + } + + public async Task UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null) + { + var existing = await _context.KanbanTaskNotes.FindAsync(note.Id) + ?? throw new InvalidOperationException("Notiz nicht gefunden."); + + existing.Title = note.Title; + existing.Content = note.Content; + existing.LastEditedByUserId = userId; + existing.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + return existing; + } + + public async Task DeleteNoteAsync(Guid noteId) + { + var note = await _context.KanbanTaskNotes.FindAsync(noteId); + if (note != null) + { + _context.KanbanTaskNotes.Remove(note); + await _context.SaveChangesAsync(); + } + } + + #endregion + + #region Attachments + + public async Task> GetAttachmentsAsync(Guid taskId) + { + return await _context.KanbanTaskAttachments + .Include(a => a.UploadedByUser) + .Where(a => a.TaskId == taskId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + } + + public async Task GetAttachmentByIdAsync(Guid id) + { + return await _context.KanbanTaskAttachments.FindAsync(id); + } + + public async Task AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId) + { + if (content.Length > KanbanTaskAttachment.MaxFileSize) + { + throw new InvalidOperationException($"Datei ist zu groß. Maximum: {KanbanTaskAttachment.MaxFileSize / (1024 * 1024)} MB"); + } + + string? filePath = null; + var storageSettings = await _fileStorageService.GetSettingsAsync(); + + // Try to save to file storage if configured + if (storageSettings.IsEnabled && storageSettings.IsPathAccessible()) + { + try + { + filePath = await _fileStorageService.SaveFileAsync(content, fileName, "kanban-attachments"); + _logger.LogInformation("Attachment saved to file storage: {FilePath}", filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save attachment to file storage, falling back to database"); + filePath = null; + } + } + + var attachment = new KanbanTaskAttachment + { + Id = Guid.NewGuid(), + TaskId = taskId, + FileName = fileName, + ContentType = contentType, + FileSize = content.Length, + FilePath = filePath, + #pragma warning disable CS0618 // Type or member is obsolete + FileContent = filePath == null ? content : null, // Only store in DB if file storage failed + #pragma warning restore CS0618 + UploadedByUserId = userId, + CreatedAt = DateTime.UtcNow + }; + + _context.KanbanTaskAttachments.Add(attachment); + try + { + await _context.SaveChangesAsync(); + } + catch + { + // Clean up orphaned file if DB save failed + if (filePath != null) + { + try { await _fileStorageService.DeleteFileAsync(filePath); } + catch (Exception cleanupEx) { _logger.LogWarning(cleanupEx, "Failed to clean up orphaned attachment file: {FilePath}", filePath); } + } + throw; + } + + await AddActivityAsync(taskId, $"Datei '{fileName}' hinzugefügt", userId); + + return attachment; + } + + public async Task GetAttachmentContentAsync(Guid attachmentId) + { + var attachment = await _context.KanbanTaskAttachments.FindAsync(attachmentId); + if (attachment == null) + return null; + + // Try file storage first + if (!string.IsNullOrEmpty(attachment.FilePath)) + { + var content = await _fileStorageService.ReadFileAsync(attachment.FilePath); + if (content != null) + return content; + } + + // Fall back to database content (legacy) + #pragma warning disable CS0618 + return attachment.FileContent; + #pragma warning restore CS0618 + } + + public async Task DeleteAttachmentAsync(Guid attachmentId) + { + var attachment = await _context.KanbanTaskAttachments.FindAsync(attachmentId); + if (attachment != null) + { + var taskId = attachment.TaskId; + var fileName = attachment.FileName; + + // Delete from file storage if applicable + if (!string.IsNullOrEmpty(attachment.FilePath)) + { + try + { + await _fileStorageService.DeleteFileAsync(attachment.FilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete attachment file: {FilePath}", attachment.FilePath); + } + } + + _context.KanbanTaskAttachments.Remove(attachment); + await _context.SaveChangesAsync(); + + await AddActivityAsync(taskId, $"Datei '{fileName}' entfernt"); + } + } + + #endregion + + #region Task Labels + + public async Task AddLabelToTaskAsync(Guid taskId, Guid labelId) + { + var exists = await _context.KanbanTaskLabels + .AnyAsync(tl => tl.TaskId == taskId && tl.LabelId == labelId); + + if (!exists) + { + _context.KanbanTaskLabels.Add(new KanbanTaskLabel + { + TaskId = taskId, + LabelId = labelId + }); + await _context.SaveChangesAsync(); + } + } + + public async Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId) + { + await _context.KanbanTaskLabels + .Where(tl => tl.TaskId == taskId && tl.LabelId == labelId) + .ExecuteDeleteAsync(); + } + + public async Task SetTaskLabelsAsync(Guid taskId, List labelIds) + { + // Remove existing at DB level + await _context.KanbanTaskLabels + .Where(tl => tl.TaskId == taskId) + .ExecuteDeleteAsync(); + + // Detach any locally tracked entities to avoid duplicate key conflicts + var trackedLabels = _context.ChangeTracker.Entries() + .Where(e => e.Entity.TaskId == taskId) + .ToList(); + foreach (var entry in trackedLabels) + entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached; + + // Add new + foreach (var labelId in labelIds) + { + _context.KanbanTaskLabels.Add(new KanbanTaskLabel + { + TaskId = taskId, + LabelId = labelId + }); + } + + await _context.SaveChangesAsync(); + } + + #endregion + + #region Task Assignees + + public async Task AddAssigneeAsync(Guid taskId, Guid userId) + { + var exists = await _context.KanbanTaskAssignees + .AnyAsync(ta => ta.TaskId == taskId && ta.UserId == userId); + + if (!exists) + { + _context.KanbanTaskAssignees.Add(new KanbanTaskAssignee + { + TaskId = taskId, + UserId = userId, + AssignedAt = DateTime.UtcNow + }); + await _context.SaveChangesAsync(); + } + } + + public async Task RemoveAssigneeAsync(Guid taskId, Guid userId) + { + await _context.KanbanTaskAssignees + .Where(ta => ta.TaskId == taskId && ta.UserId == userId) + .ExecuteDeleteAsync(); + } + + public async Task SetAssigneesAsync(Guid taskId, List userIds, Guid? changedByUserId = null) + { + var currentAssignees = await _context.KanbanTaskAssignees + .Where(ta => ta.TaskId == taskId) + .Select(ta => ta.UserId) + .ToListAsync(); + + var added = userIds.Except(currentAssignees).ToList(); + var removed = currentAssignees.Except(userIds).ToList(); + + // Remove old at DB level + await _context.KanbanTaskAssignees + .Where(ta => ta.TaskId == taskId) + .ExecuteDeleteAsync(); + + // Detach any locally tracked entities to avoid duplicate key conflicts + var trackedAssignees = _context.ChangeTracker.Entries() + .Where(e => e.Entity.TaskId == taskId) + .ToList(); + foreach (var entry in trackedAssignees) + entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached; + + // Add new + foreach (var userId in userIds) + { + _context.KanbanTaskAssignees.Add(new KanbanTaskAssignee + { + TaskId = taskId, + UserId = userId, + AssignedAt = DateTime.UtcNow + }); + } + + await _context.SaveChangesAsync(); + + // Log activity + if (added.Any() || removed.Any()) + { + var users = await _context.Users + .Where(u => added.Contains(u.Id) || removed.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.DisplayName ?? u.Username); + + var activities = new List(); + if (added.Any()) + { + var names = added.Select(id => users.GetValueOrDefault(id, "Unbekannt")); + activities.Add($"Zugewiesen: {string.Join(", ", names)}"); + } + if (removed.Any()) + { + var names = removed.Select(id => users.GetValueOrDefault(id, "Unbekannt")); + activities.Add($"Entfernt: {string.Join(", ", names)}"); + } + + await AddActivityAsync(taskId, string.Join("; ", activities), changedByUserId); + } + } + + #endregion + + #region Task Customers + + public async Task AddCustomerToTaskAsync(Guid taskId, Guid customerId) + { + var exists = await _context.KanbanTaskCustomers + .AnyAsync(tc => tc.TaskId == taskId && tc.CustomerId == customerId); + + if (!exists) + { + _context.KanbanTaskCustomers.Add(new KanbanTaskCustomer + { + TaskId = taskId, + CustomerId = customerId + }); + await _context.SaveChangesAsync(); + + var customer = await _context.Customers.FindAsync(customerId); + if (customer != null) + { + await AddActivityAsync(taskId, $"Kunde '{customer.FullName}' verknüpft"); + } + } + } + + public async Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId) + { + var customer = await _context.Customers.FindAsync(customerId); + + await _context.KanbanTaskCustomers + .Where(tc => tc.TaskId == taskId && tc.CustomerId == customerId) + .ExecuteDeleteAsync(); + + // Also remove tire stacks for this customer + await _context.KanbanTaskTireStacks + .Where(ts => ts.TaskId == taskId && ts.TireStack!.CustomerId == customerId) + .ExecuteDeleteAsync(); + + if (customer != null) + { + await AddActivityAsync(taskId, $"Kunde '{customer.FullName}' entfernt"); + } + } + + public async Task SetTaskCustomersAsync(Guid taskId, Dictionary customerVehicleMap) + { + await _context.KanbanTaskCustomers + .Where(tc => tc.TaskId == taskId) + .ExecuteDeleteAsync(); + + // Detach any locally tracked entities to avoid duplicate key conflicts + var trackedCustomers = _context.ChangeTracker.Entries() + .Where(e => e.Entity.TaskId == taskId) + .ToList(); + foreach (var entry in trackedCustomers) + entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached; + + foreach (var (customerId, vehicleId) in customerVehicleMap) + { + _context.KanbanTaskCustomers.Add(new KanbanTaskCustomer + { + TaskId = taskId, + CustomerId = customerId, + VehicleId = vehicleId + }); + } + + await _context.SaveChangesAsync(); + } + + #endregion + + #region Task TireStacks + + public async Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId) + { + var exists = await _context.KanbanTaskTireStacks + .AnyAsync(ts => ts.TaskId == taskId && ts.TireStackId == tireStackId); + + if (!exists) + { + _context.KanbanTaskTireStacks.Add(new KanbanTaskTireStack + { + TaskId = taskId, + TireStackId = tireStackId + }); + await _context.SaveChangesAsync(); + } + } + + public async Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId) + { + await _context.KanbanTaskTireStacks + .Where(ts => ts.TaskId == taskId && ts.TireStackId == tireStackId) + .ExecuteDeleteAsync(); + } + + public async Task SetTaskTireStacksAsync(Guid taskId, List tireStackIds) + { + await _context.KanbanTaskTireStacks + .Where(ts => ts.TaskId == taskId) + .ExecuteDeleteAsync(); + + // Detach any locally tracked entities to avoid duplicate key conflicts + var trackedTireStacks = _context.ChangeTracker.Entries() + .Where(e => e.Entity.TaskId == taskId) + .ToList(); + foreach (var entry in trackedTireStacks) + entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached; + + foreach (var tireStackId in tireStackIds) + { + _context.KanbanTaskTireStacks.Add(new KanbanTaskTireStack + { + TaskId = taskId, + TireStackId = tireStackId + }); + } + + await _context.SaveChangesAsync(); + } + + #endregion + + #region Templates + + public async Task> GetTemplatesAsync() + { + return await _context.KanbanTaskTemplates + .AsSplitQuery() // Prevents cartesian explosion with multiple includes + .Include(t => t.DefaultColumn) + .Include(t => t.Todos.OrderBy(todo => todo.SortOrder)) + .Include(t => t.TemplateLabels).ThenInclude(tl => tl.Label) + .Where(t => t.IsActive) + .OrderBy(t => t.SortOrder) + .ToListAsync(); + } + + public async Task GetTemplateByIdAsync(Guid id) + { + return await _context.KanbanTaskTemplates + .AsSplitQuery() + .Include(t => t.DefaultColumn) + .Include(t => t.Todos.OrderBy(todo => todo.SortOrder)) + .Include(t => t.TemplateLabels).ThenInclude(tl => tl.Label) + .FirstOrDefaultAsync(t => t.Id == id && t.IsActive); + } + + public async Task CreateTemplateAsync(KanbanTaskTemplate template) + { + template.Id = Guid.NewGuid(); + template.IsActive = true; + template.CreatedAt = DateTime.UtcNow; + template.UpdatedAt = DateTime.UtcNow; + + var maxOrder = await _context.KanbanTaskTemplates.MaxAsync(t => (int?)t.SortOrder) ?? -1; + template.SortOrder = maxOrder + 1; + + _context.KanbanTaskTemplates.Add(template); + await _context.SaveChangesAsync(); + return template; + } + + public async Task UpdateTemplateAsync(KanbanTaskTemplate template) + { + var existing = await _context.KanbanTaskTemplates.FindAsync(template.Id) + ?? throw new InvalidOperationException("Vorlage nicht gefunden."); + + existing.Name = template.Name; + existing.Description = template.Description; + existing.TitlePattern = template.TitlePattern; + existing.DefaultDescription = template.DefaultDescription; + existing.DefaultColumnId = template.DefaultColumnId; + existing.DefaultPriority = template.DefaultPriority; + existing.DefaultEstimatedMinutes = template.DefaultEstimatedMinutes; + existing.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + return existing; + } + + public async Task DeleteTemplateAsync(Guid id) + { + var template = await _context.KanbanTaskTemplates.FindAsync(id) + ?? throw new InvalidOperationException("Vorlage nicht gefunden."); + + template.IsActive = false; + template.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + + public async Task AddTemplateTodoAsync(Guid templateId, string text) + { + var maxOrder = await _context.KanbanTaskTemplateTodos + .Where(t => t.TemplateId == templateId) + .MaxAsync(t => (int?)t.SortOrder) ?? -1; + + var todo = new KanbanTaskTemplateTodo + { + Id = Guid.NewGuid(), + TemplateId = templateId, + Text = text, + SortOrder = maxOrder + 1 + }; + + _context.KanbanTaskTemplateTodos.Add(todo); + await _context.SaveChangesAsync(); + return todo; + } + + public async Task DeleteTemplateTodoAsync(Guid todoId) + { + var todo = await _context.KanbanTaskTemplateTodos.FindAsync(todoId); + if (todo != null) + { + _context.KanbanTaskTemplateTodos.Remove(todo); + await _context.SaveChangesAsync(); + } + } + + public async Task SyncTemplateTodosAsync(Guid templateId, List todos) + { + // Remove all existing todos + await _context.KanbanTaskTemplateTodos + .Where(t => t.TemplateId == templateId) + .ExecuteDeleteAsync(); + + // Add new todos + for (int i = 0; i < todos.Count; i++) + { + _context.KanbanTaskTemplateTodos.Add(new KanbanTaskTemplateTodo + { + Id = Guid.NewGuid(), + TemplateId = templateId, + Text = todos[i].Text, + SortOrder = i + }); + } + + await _context.SaveChangesAsync(); + } + + public async Task SetTemplateLabelsAsync(Guid templateId, List labelIds) + { + await _context.KanbanTaskTemplateLabels + .Where(tl => tl.TemplateId == templateId) + .ExecuteDeleteAsync(); + + foreach (var labelId in labelIds) + { + _context.KanbanTaskTemplateLabels.Add(new KanbanTaskTemplateLabel + { + TemplateId = templateId, + LabelId = labelId + }); + } + + await _context.SaveChangesAsync(); + } + + #endregion + + #region Statistics + + public async Task GetStatisticsAsync() + { + var today = DateTime.UtcNow.Date; + var weekStart = today.AddDays(-(int)today.DayOfWeek); + + // Get completed column IDs to exclude from "open" counts + var completedColumnIds = await _context.KanbanColumns + .Where(c => c.IsCompletedColumn && c.IsActive) + .Select(c => c.Id) + .ToListAsync(); + + // Use database-side counting for better performance + var baseQuery = _context.KanbanTasks.Where(t => !t.IsArchived); + var openTasksQuery = baseQuery.Where(t => !completedColumnIds.Contains(t.ColumnId)); + + var totalOpenTasks = await openTasksQuery.CountAsync(); + var tasksDueToday = await openTasksQuery.CountAsync(t => t.DueDate.HasValue && t.DueDate.Value.Date == today); + var overdueTasks = await openTasksQuery.CountAsync(t => t.DueDate.HasValue && t.DueDate.Value.Date < today); + var completedThisWeek = await baseQuery.CountAsync(t => t.CompletedAt.HasValue && t.CompletedAt.Value >= weekStart); + + // Get tasks per column with a single grouped query + var tasksPerColumn = await baseQuery + .GroupBy(t => t.ColumnId) + .Select(g => new { ColumnId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ColumnId, x => x.Count); + + return new KanbanStatistics + { + TotalTasks = totalOpenTasks, + TasksDueToday = tasksDueToday, + OverdueTasks = overdueTasks, + CompletedThisWeek = completedThisWeek, + TasksPerColumn = tasksPerColumn + }; + } + + #endregion +} diff --git a/Lagerbestand/Services/LexwareSyncService.cs b/Lagerbestand/Services/LexwareSyncService.cs new file mode 100644 index 0000000..710faf6 --- /dev/null +++ b/Lagerbestand/Services/LexwareSyncService.cs @@ -0,0 +1,515 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +/// +/// Result of a Lexware customer sync operation +/// +public class LexwareSyncResult +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public int CustomersCreated { get; set; } + public int CustomersUpdated { get; set; } + public int CustomersDeactivated { get; set; } + public int TotalProcessed { get; set; } +} + +/// +/// Lexware API contact response models +/// +public class LexwareContactsResponse +{ + [JsonPropertyName("content")] + public List Content { get; set; } = new(); + + [JsonPropertyName("totalPages")] + public int TotalPages { get; set; } + + [JsonPropertyName("totalElements")] + public int TotalElements { get; set; } + + [JsonPropertyName("last")] + public bool Last { get; set; } + + [JsonPropertyName("number")] + public int Number { get; set; } +} + +public class LexwareContact +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("organizationId")] + public string? OrganizationId { get; set; } + + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("roles")] + public LexwareRoles? Roles { get; set; } + + [JsonPropertyName("company")] + public LexwareCompany? Company { get; set; } + + [JsonPropertyName("person")] + public LexwarePerson? Person { get; set; } + + [JsonPropertyName("addresses")] + public LexwareAddresses? Addresses { get; set; } + + [JsonPropertyName("emailAddresses")] + public LexwareEmailAddresses? EmailAddresses { get; set; } + + [JsonPropertyName("phoneNumbers")] + public LexwarePhoneNumbers? PhoneNumbers { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } + + [JsonPropertyName("archived")] + public bool Archived { get; set; } +} + +public class LexwareRoles +{ + [JsonPropertyName("customer")] + public LexwareCustomerRole? Customer { get; set; } +} + +public class LexwareCustomerRole +{ + [JsonPropertyName("number")] + public long? Number { get; set; } +} + +public class LexwareCompany +{ + [JsonPropertyName("name")] + public string? Name { get; set; } +} + +public class LexwarePerson +{ + [JsonPropertyName("salutation")] + public string? Salutation { get; set; } + + [JsonPropertyName("firstName")] + public string? FirstName { get; set; } + + [JsonPropertyName("lastName")] + public string? LastName { get; set; } +} + +public class LexwareAddresses +{ + [JsonPropertyName("billing")] + public List? Billing { get; set; } +} + +public class LexwareAddress +{ + [JsonPropertyName("street")] + public string? Street { get; set; } + + [JsonPropertyName("zip")] + public string? Zip { get; set; } + + [JsonPropertyName("city")] + public string? City { get; set; } + + [JsonPropertyName("countryCode")] + public string? CountryCode { get; set; } +} + +public class LexwareEmailAddresses +{ + [JsonPropertyName("business")] + public List? Business { get; set; } + + [JsonPropertyName("private")] + public List? Private { get; set; } + + [JsonPropertyName("other")] + public List? Other { get; set; } +} + +public class LexwarePhoneNumbers +{ + [JsonPropertyName("business")] + public List? Business { get; set; } + + [JsonPropertyName("private")] + public List? Private { get; set; } + + [JsonPropertyName("mobile")] + public List? Mobile { get; set; } +} + +public interface ILexwareSyncService +{ + /// + /// Tests the API connection with the given settings + /// + Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings); + + /// + /// Syncs customers from Lexware API to the local database + /// + Task SyncCustomersAsync(CancellationToken cancellationToken = default); +} + +public class LexwareSyncService : ILexwareSyncService +{ + private readonly AppDbContext _context; + private readonly ISiteSettingsService _siteSettingsService; + private readonly ICustomerService _customerService; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + public LexwareSyncService( + AppDbContext context, + ISiteSettingsService siteSettingsService, + ICustomerService customerService, + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _context = context; + _siteSettingsService = siteSettingsService; + _customerService = customerService; + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + public async Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings) + { + if (!settings.IsConfigured) + return (false, "API URL und API-Key müssen konfiguriert sein."); + + try + { + using var client = CreateHttpClient(settings); + var response = await client.GetAsync("contacts?page=0&size=1"); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return (true, $"Verbindung erfolgreich! {result?.TotalElements ?? 0} Kontakte gefunden."); + } + + return response.StatusCode switch + { + System.Net.HttpStatusCode.Unauthorized => (false, "Authentifizierung fehlgeschlagen. Bitte API-Key prüfen."), + System.Net.HttpStatusCode.Forbidden => (false, "Zugriff verweigert. API-Key hat keine ausreichenden Berechtigungen."), + System.Net.HttpStatusCode.NotFound => (false, "API-Endpunkt nicht gefunden. Bitte URL prüfen."), + _ => (false, $"API-Fehler: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}") + }; + } + catch (HttpRequestException ex) + { + return (false, $"Verbindungsfehler: {ex.Message}"); + } + catch (TaskCanceledException) + { + return (false, "Zeitüberschreitung bei der Verbindung."); + } + catch (Exception ex) + { + return (false, $"Fehler: {ex.Message}"); + } + } + + public async Task SyncCustomersAsync(CancellationToken cancellationToken = default) + { + var settings = await _siteSettingsService.GetLexwareApiSettingsAsync(); + + if (!settings.IsEnabled || !settings.IsConfigured) + { + return new LexwareSyncResult + { + Success = false, + Message = "Lexware API ist nicht konfiguriert oder deaktiviert." + }; + } + + var result = new LexwareSyncResult(); + + try + { + _logger.LogInformation("Starting Lexware customer sync..."); + + using var client = CreateHttpClient(settings); + var allContacts = new List(); + + // Fetch all pages of customers + var page = 0; + bool lastPage = false; + while (!lastPage) + { + cancellationToken.ThrowIfCancellationRequested(); + + var response = await client.GetAsync($"contacts?page={page}&size=250&customer=true", cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var pageResult = JsonSerializer.Deserialize(json); + + if (pageResult?.Content != null) + { + // Only include contacts with customer role that are not archived + allContacts.AddRange(pageResult.Content.Where(c => !c.Archived)); + } + + lastPage = pageResult?.Last ?? true; + page++; + } + + _logger.LogInformation("Fetched {Count} customer contacts from Lexware", allContacts.Count); + + // Get all existing customers indexed by CustomerNumber (include inactive for reactivation) + var existingCustomers = await _context.Customers + .Include(c => c.Vehicles.Where(v => v.IsActive)) + .ToListAsync(cancellationToken); + + var customersByNumber = existingCustomers + .Where(c => !string.IsNullOrEmpty(c.CustomerNumber)) + .ToDictionary(c => c.CustomerNumber, c => c); + + foreach (var contact in allContacts) + { + result.TotalProcessed++; + + var customerNumber = contact.Roles?.Customer?.Number?.ToString() ?? ""; + if (string.IsNullOrEmpty(customerNumber)) + continue; + + var firstName = contact.Person?.FirstName?.Trim() ?? ""; + var lastName = contact.Person?.LastName?.Trim() ?? ""; + + // Skip contacts without a name + if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName)) + { + // Use company name as last name fallback + if (!string.IsNullOrEmpty(contact.Company?.Name)) + { + lastName = contact.Company.Name; + } + else + { + continue; + } + } + + var company = contact.Company?.Name?.Trim(); + var phone = GetFirstPhone(contact.PhoneNumbers); + var email = GetFirstEmail(contact.EmailAddresses); + var notes = contact.Note?.Trim(); + + if (customersByNumber.TryGetValue(customerNumber, out var existing)) + { + // Update existing customer + bool changed = false; + + if (existing.FirstName != firstName) { existing.FirstName = firstName; changed = true; } + if (existing.LastName != lastName) { existing.LastName = lastName; changed = true; } + if (existing.Company != company) { existing.Company = company; changed = true; } + if (existing.Phone != phone) { existing.Phone = phone; changed = true; } + if (existing.Email != email) { existing.Email = email; changed = true; } + if (existing.Notes != notes) { existing.Notes = notes; changed = true; } + + // Reactivate if previously deactivated + if (!existing.IsActive) { existing.IsActive = true; changed = true; } + + if (changed) + { + existing.UpdatedAt = DateTime.UtcNow; + _context.Customers.Update(existing); + result.CustomersUpdated++; + } + } + else + { + // Create new customer + var newCustomer = new Customer + { + CustomerNumber = customerNumber, + FirstName = string.IsNullOrEmpty(firstName) ? "-" : firstName, + LastName = string.IsNullOrEmpty(lastName) ? "-" : lastName, + Company = company, + Phone = phone, + Email = email, + Notes = notes, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + _context.Customers.Add(newCustomer); + result.CustomersCreated++; + } + } + + await _context.SaveChangesAsync(cancellationToken); + + // Deactivate customers not found in Lexware (Lexware is single source of truth) + var lexwareCustomerNumbers = allContacts + .Select(c => c.Roles?.Customer?.Number?.ToString()) + .Where(n => !string.IsNullOrEmpty(n)) + .ToHashSet(); + + var customersToDeactivate = await _context.Customers + .Where(c => c.IsActive && !string.IsNullOrEmpty(c.CustomerNumber) && !lexwareCustomerNumbers.Contains(c.CustomerNumber)) + .ToListAsync(cancellationToken); + + foreach (var customer in customersToDeactivate) + { + customer.IsActive = false; + customer.UpdatedAt = DateTime.UtcNow; + result.CustomersDeactivated++; + _logger.LogWarning("Customer {CustomerNumber} ({Name}) deactivated - not found in Lexware", + customer.CustomerNumber, customer.FullName); + } + + if (customersToDeactivate.Count > 0) + await _context.SaveChangesAsync(cancellationToken); + + // Update sync timestamp + settings.LastSyncAt = DateTime.UtcNow; + settings.LastSyncCount = result.TotalProcessed; + settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert"; + await _siteSettingsService.SaveLexwareApiSettingsAsync(settings); + + result.Success = true; + result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert ({result.TotalProcessed} verarbeitet)"; + + _logger.LogInformation("Lexware sync completed: {Message}", result.Message); + } + catch (HttpRequestException ex) + { + result.Success = false; + result.Message = $"API-Verbindungsfehler: {ex.Message}"; + _logger.LogError(ex, "Lexware sync failed: HTTP error"); + } + catch (Exception ex) + { + result.Success = false; + result.Message = $"Synchronisierungsfehler: {ex.Message}"; + _logger.LogError(ex, "Lexware sync failed"); + } + + return result; + } + + private HttpClient CreateHttpClient(LexwareApiSettings settings) + { + var client = _httpClientFactory.CreateClient("LexwareApi"); + client.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.Timeout = TimeSpan.FromSeconds(30); + return client; + } + + private static string? GetFirstPhone(LexwarePhoneNumbers? phones) + { + if (phones == null) return null; + + return phones.Mobile?.FirstOrDefault() + ?? phones.Business?.FirstOrDefault() + ?? phones.Private?.FirstOrDefault(); + } + + private static string? GetFirstEmail(LexwareEmailAddresses? emails) + { + if (emails == null) return null; + + return emails.Business?.FirstOrDefault() + ?? emails.Private?.FirstOrDefault() + ?? emails.Other?.FirstOrDefault(); + } +} + +/// +/// Background service that runs daily Lexware customer sync +/// +public class LexwareSyncBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public LexwareSyncBackgroundService(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Lexware sync background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + var settings = await settingsService.GetLexwareApiSettingsAsync(); + + if (settings is { IsEnabled: true, IsConfigured: true, AutoSyncEnabled: true }) + { + // Parse target time + if (TimeSpan.TryParse(settings.AutoSyncTime, out var targetTime)) + { + var now = DateTime.UtcNow; + var nextRun = now.Date.Add(targetTime); + + // If we've already passed the target time today, schedule for tomorrow + if (now > nextRun) + nextRun = nextRun.AddDays(1); + + var delay = nextRun - now; + _logger.LogDebug("Lexware auto-sync scheduled for {NextRun} (in {Delay})", nextRun, delay); + + await Task.Delay(delay, stoppingToken); + + if (!stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Running scheduled Lexware customer sync..."); + using var syncScope = _serviceProvider.CreateScope(); + var syncService = syncScope.ServiceProvider.GetRequiredService(); + var result = await syncService.SyncCustomersAsync(stoppingToken); + _logger.LogInformation("Scheduled Lexware sync result: {Message}", result.Message); + } + } + else + { + // Invalid time format, wait and retry + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + else + { + // Not configured or disabled, check again in 15 minutes + await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in Lexware sync background service"); + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + + _logger.LogInformation("Lexware sync background service stopped"); + } +} diff --git a/Lagerbestand/Services/LocationCodeService.cs b/Lagerbestand/Services/LocationCodeService.cs new file mode 100644 index 0000000..301e2f5 --- /dev/null +++ b/Lagerbestand/Services/LocationCodeService.cs @@ -0,0 +1,275 @@ +using System.Text.RegularExpressions; +using Lagerbestand.Data; +using Lagerbestand.Exceptions; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +/// +/// Result of location code validation. +/// +public record LocationCodeValidationResult( + bool IsValid, + string? ErrorMessage = null, + AreaType? Area = null, + int? Row = null, + ShelfPosition? Position = null, + int? SlotNumber = null +); + +/// +/// Parsed components of a location code. +/// +public record ParsedLocationCode(AreaType Area, int Row, ShelfPosition Position, int SlotNumber) +{ + /// + /// Generates the 4-digit code string. + /// + public string ToCode() => $"{(int)Area}{Row}{(int)Position}{SlotNumber}"; +} + +/// +/// Service for generating, parsing, and validating storage location codes. +/// Location codes follow the ABCD format where: +/// - A: Gebäude (1=Werkstatt, 2=Lager) +/// - B: Reihe (Werkstatt: 1-3, Lager: 1-5) +/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten) +/// - D: Platz (1-9) +/// +public interface ILocationCodeService +{ + /// + /// Generates a location code from component parts. + /// + /// Storage building + /// Row number (Werkstatt: 1-3, Lager: 1-5) + /// Shelf position (side/level) + /// Slot number (1-9) + /// 4-digit location code string + string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot); + + /// + /// Parses a location code string into its component parts. + /// + /// 4-digit location code + /// Parsed location code components + /// If code format is invalid + ParsedLocationCode ParseLocationCode(string code); + + /// + /// Tries to parse a location code without throwing exceptions. + /// + bool TryParseLocationCode(string code, out ParsedLocationCode? result); + + /// + /// Validates a location code format. + /// + /// Code to validate + /// Validation result with details + LocationCodeValidationResult ValidateLocationCode(string code); + + /// + /// Finds the next available (existing, unoccupied) location in a given area and row. + /// + /// Storage building + /// Row number + /// Parsed code of next free location, or null if row is full + Task GetNextAvailableSlotAsync(AreaType area, int row); + + /// + /// Checks if a specific location code is available (exists and not occupied). + /// + Task IsLocationAvailableAsync(string code); + + /// + /// Checks if a location code already exists in the database. + /// + Task CodeExistsAsync(string code); + + /// + /// Gets all occupied slots in a specific area and row. + /// + Task> GetOccupiedSlotsAsync(AreaType area, int row); + + /// + /// Gets all existing slot numbers in a specific area and row. + /// + Task> GetExistingSlotsAsync(AreaType area, int row); + + /// + /// Formats a location code for display with human-readable text. + /// + string FormatForDisplay(string code); +} + +/// +public class LocationCodeService : ILocationCodeService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + private static readonly Regex CodeRegex = new(StorageLocation.LocationCodePattern, RegexOptions.Compiled); + + public LocationCodeService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + public string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot) + { + var maxRow = area.GetMaxRow(); + if (row < StorageLocation.MinRow || row > maxRow) + throw new ArgumentOutOfRangeException(nameof(row), + $"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen."); + + if (slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot) + throw new ArgumentOutOfRangeException(nameof(slot), + $"Platz muss zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen."); + + return $"{(int)area}{row}{(int)position}{slot}"; + } + + /// + public ParsedLocationCode ParseLocationCode(string code) + { + var validation = ValidateLocationCode(code); + if (!validation.IsValid) + throw new InvalidLocationCodeException(code, validation.ErrorMessage ?? "Ungültiges Format"); + + return new ParsedLocationCode( + validation.Area!.Value, + validation.Row!.Value, + validation.Position!.Value, + validation.SlotNumber!.Value + ); + } + + /// + public bool TryParseLocationCode(string code, out ParsedLocationCode? result) + { + result = null; + var validation = ValidateLocationCode(code); + if (!validation.IsValid) + return false; + + result = new ParsedLocationCode( + validation.Area!.Value, + validation.Row!.Value, + validation.Position!.Value, + validation.SlotNumber!.Value + ); + return true; + } + + /// + public LocationCodeValidationResult ValidateLocationCode(string code) + { + if (string.IsNullOrWhiteSpace(code)) + return new LocationCodeValidationResult(false, "Lagerplatz-Code darf nicht leer sein."); + + code = code.Trim(); + + if (code.Length != 4) + return new LocationCodeValidationResult(false, + $"Lagerplatz-Code muss genau 4 Zeichen lang sein (aktuell: {code.Length})."); + + if (!CodeRegex.IsMatch(code)) + return new LocationCodeValidationResult(false, + "Ungültiges Format. Erwartet: ABCD wobei A=Gebäude (1-2), B=Reihe, C=Seite/Ebene (1-4), D=Platz (1-9)."); + + // Parse area (position A) + if (!AreaTypeExtensions.TryFromCode(code[0], out var area)) + return new LocationCodeValidationResult(false, + $"Ungültiges Gebäude '{code[0]}'. Erlaubt: 1 (Werkstatt), 2 (Lager)."); + + // Parse row (position B) - max row depends on building + if (!int.TryParse(code[1].ToString(), out var row)) + return new LocationCodeValidationResult(false, + $"Ungültige Reihe '{code[1]}'."); + + var maxRow = area.GetMaxRow(); + if (row < StorageLocation.MinRow || row > maxRow) + return new LocationCodeValidationResult(false, + $"Reihe {row} außerhalb des gültigen Bereichs für {area.GetDisplayName()} ({StorageLocation.MinRow}-{maxRow})."); + + // Parse position (position C) + if (!ShelfPositionExtensions.TryFromCode(code[2], out var position)) + return new LocationCodeValidationResult(false, + $"Ungültige Seite/Ebene '{code[2]}'. Erlaubt: 1 (links oben), 2 (links unten), 3 (rechts oben), 4 (rechts unten)."); + + // Parse slot (position D) + if (!int.TryParse(code[3].ToString(), out var slot) || slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot) + return new LocationCodeValidationResult(false, + $"Ungültiger Platz '{code[3]}'. Erlaubt: {StorageLocation.MinSlot}-{StorageLocation.MaxSlot}."); + + return new LocationCodeValidationResult(true, null, area, row, position, slot); + } + + /// + public async Task GetNextAvailableSlotAsync(AreaType area, int row) + { + var freeLocation = await _context.StorageLocations + .AsNoTracking() + .Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsAvailable && !l.IsOccupied) + .OrderBy(l => l.SlotNumber) + .ThenBy(l => l.Position) + .FirstOrDefaultAsync(); + + if (freeLocation == null) + return null; // Row is full + + return new ParsedLocationCode(freeLocation.Area, freeLocation.Row, freeLocation.Position, freeLocation.SlotNumber); + } + + /// + public async Task IsLocationAvailableAsync(string code) + { + var validation = ValidateLocationCode(code); + if (!validation.IsValid) + return false; + + var location = await _context.StorageLocations + .FirstOrDefaultAsync(l => l.Code == code && l.IsActive); + + return location != null && !location.IsOccupied && location.IsAvailable; + } + + /// + public async Task CodeExistsAsync(string code) + { + return await _context.StorageLocations + .AnyAsync(l => l.Code == code); + } + + /// + public async Task> GetOccupiedSlotsAsync(AreaType area, int row) + { + return await _context.StorageLocations + .Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsOccupied) + .Select(l => l.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + } + + /// + public async Task> GetExistingSlotsAsync(AreaType area, int row) + { + return await _context.StorageLocations + .Where(l => l.Area == area && l.Row == row && l.IsActive) + .Select(l => l.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + } + + /// + public string FormatForDisplay(string code) + { + if (!TryParseLocationCode(code, out var parsed) || parsed == null) + return code; + + return $"{parsed.Area.GetDisplayName()}, Reihe {parsed.Row}, {parsed.Position.GetDisplayName()}, Platz {parsed.SlotNumber}"; + } +} diff --git a/Lagerbestand/Services/SiteSettingsService.cs b/Lagerbestand/Services/SiteSettingsService.cs new file mode 100644 index 0000000..240c3cd --- /dev/null +++ b/Lagerbestand/Services/SiteSettingsService.cs @@ -0,0 +1,452 @@ +using System.Text.Json; +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +public interface ISiteSettingsService +{ + Task GetPromoBannerSettingsAsync(); + Task SavePromoBannerSettingsAsync(PromoBannerSettings settings); + Task GetLexwareApiSettingsAsync(); + Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings); + Task GetTwoFactorSettingsAsync(); + Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings); + Task GetSmtpSettingsAsync(); + Task SaveSmtpSettingsAsync(SmtpSettings settings); + Task GetSessionSettingsAsync(); + Task SaveSessionSettingsAsync(SessionSettings settings); + + /// + /// Gets a raw setting value by key + /// + Task GetValueAsync(string key); + + /// + /// Sets a raw setting value by key + /// + Task SetValueAsync(string key, string value); +} + +public class SiteSettingsService : ISiteSettingsService +{ + private const string PromoBannerKey = "PromoBanner"; + private const string LexwareApiKey = "LexwareApi"; + private const string TwoFactorKey = "TwoFactor"; + private const string SmtpKey = "Smtp"; + private const string SessionKey = "Session"; + + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public SiteSettingsService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task GetPromoBannerSettingsAsync() + { + try + { + // Quick timeout to prevent blocking app startup + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var setting = await _context.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == PromoBannerKey, cts.Token); + + if (setting == null) + { + return new PromoBannerSettings(); + } + + var settings = JsonSerializer.Deserialize(setting.Value); + return settings ?? new PromoBannerSettings(); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Banner settings query timed out, returning default"); + return new PromoBannerSettings(); + } + catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146) + { + // Table doesn't exist yet - this is normal during first startup + _logger.LogDebug("SiteSettings table not found, returning default settings"); + return new PromoBannerSettings(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load promo banner settings"); + return new PromoBannerSettings(); + } + } + + public async Task SavePromoBannerSettingsAsync(PromoBannerSettings settings) + { + try + { + var existing = await _context.SiteSettings + .FirstOrDefaultAsync(s => s.Key == PromoBannerKey); + + var json = JsonSerializer.Serialize(settings); + + if (existing != null) + { + existing.Value = json; + existing.UpdatedAt = DateTime.UtcNow; + _context.SiteSettings.Update(existing); + } + else + { + var newSetting = new SiteSettings + { + Key = PromoBannerKey, + Value = json, + CreatedAt = DateTime.UtcNow + }; + _context.SiteSettings.Add(newSetting); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Promo banner settings saved successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save promo banner settings"); + throw; + } + } + + public async Task GetLexwareApiSettingsAsync() + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var setting = await _context.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == LexwareApiKey, cts.Token); + + if (setting == null) + return new LexwareApiSettings(); + + var settings = JsonSerializer.Deserialize(setting.Value); + return settings ?? new LexwareApiSettings(); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Lexware settings query timed out, returning default"); + return new LexwareApiSettings(); + } + catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146) + { + _logger.LogDebug("SiteSettings table not found, returning default Lexware settings"); + return new LexwareApiSettings(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load Lexware API settings"); + return new LexwareApiSettings(); + } + } + + public async Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings) + { + try + { + var existing = await _context.SiteSettings + .FirstOrDefaultAsync(s => s.Key == LexwareApiKey); + + var json = JsonSerializer.Serialize(settings); + + if (existing != null) + { + existing.Value = json; + existing.UpdatedAt = DateTime.UtcNow; + _context.SiteSettings.Update(existing); + } + else + { + var newSetting = new SiteSettings + { + Key = LexwareApiKey, + Value = json, + CreatedAt = DateTime.UtcNow + }; + _context.SiteSettings.Add(newSetting); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Lexware API settings saved successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Lexware API settings"); + throw; + } + } + + public async Task GetTwoFactorSettingsAsync() + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var setting = await _context.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == TwoFactorKey, cts.Token); + + if (setting == null) + return new TwoFactorSettings(); + + return JsonSerializer.Deserialize(setting.Value) ?? new TwoFactorSettings(); + } + catch (OperationCanceledException) + { + _logger.LogDebug("TwoFactor settings query timed out, returning default"); + return new TwoFactorSettings(); + } + catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146) + { + return new TwoFactorSettings(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load TwoFactor settings"); + return new TwoFactorSettings(); + } + } + + public async Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings) + { + try + { + var existing = await _context.SiteSettings + .FirstOrDefaultAsync(s => s.Key == TwoFactorKey); + + var json = JsonSerializer.Serialize(settings); + + if (existing != null) + { + existing.Value = json; + existing.UpdatedAt = DateTime.UtcNow; + _context.SiteSettings.Update(existing); + } + else + { + _context.SiteSettings.Add(new SiteSettings + { + Key = TwoFactorKey, + Value = json, + CreatedAt = DateTime.UtcNow + }); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("TwoFactor settings saved successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save TwoFactor settings"); + throw; + } + } + + public async Task GetSmtpSettingsAsync() + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var setting = await _context.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == SmtpKey, cts.Token); + + if (setting == null) + return new SmtpSettings(); + + return JsonSerializer.Deserialize(setting.Value) ?? new SmtpSettings(); + } + catch (OperationCanceledException) + { + _logger.LogDebug("SMTP settings query timed out, returning default"); + return new SmtpSettings(); + } + catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146) + { + return new SmtpSettings(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load SMTP settings"); + return new SmtpSettings(); + } + } + + public async Task SaveSmtpSettingsAsync(SmtpSettings settings) + { + try + { + var existing = await _context.SiteSettings + .FirstOrDefaultAsync(s => s.Key == SmtpKey); + + var json = JsonSerializer.Serialize(settings); + + if (existing != null) + { + existing.Value = json; + existing.UpdatedAt = DateTime.UtcNow; + _context.SiteSettings.Update(existing); + } + else + { + _context.SiteSettings.Add(new SiteSettings + { + Key = SmtpKey, + Value = json, + CreatedAt = DateTime.UtcNow + }); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("SMTP settings saved successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save SMTP settings"); + throw; + } + } + + public async Task GetSessionSettingsAsync() + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var setting = await _context.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == SessionKey, cts.Token); + + if (setting == null) + return new SessionSettings(); + + return JsonSerializer.Deserialize(setting.Value) ?? new SessionSettings(); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Session settings query timed out, returning default"); + return new SessionSettings(); + } + catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146) + { + return new SessionSettings(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load Session settings"); + return new SessionSettings(); + } + } + + public async Task SaveSessionSettingsAsync(SessionSettings settings) + { + try + { + // Clamp to safe ranges before persisting + settings.IdleTimeoutMinutes = settings.GetSafeIdleTimeoutMinutes(); + settings.CircuitRetentionHours = settings.GetSafeCircuitRetentionHours(); + + var existing = await _context.SiteSettings + .FirstOrDefaultAsync(s => s.Key == SessionKey); + + var json = JsonSerializer.Serialize(settings); + + if (existing != null) + { + existing.Value = json; + existing.UpdatedAt = DateTime.UtcNow; + _context.SiteSettings.Update(existing); + } + else + { + _context.SiteSettings.Add(new SiteSettings + { + Key = SessionKey, + Value = json, + CreatedAt = DateTime.UtcNow + }); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Session settings saved successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Session settings"); + throw; + } + } + + public async Task GetValueAsync(string key) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var setting = await _context.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == key, cts.Token); + + return setting?.Value; + } + catch (OperationCanceledException) + { + _logger.LogDebug("Settings query for '{Key}' timed out", key); + return null; + } + catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146) + { + _logger.LogDebug("SiteSettings table not found for key '{Key}'", key); + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load setting '{Key}'", key); + return null; + } + } + + public async Task SetValueAsync(string key, string value) + { + try + { + var existing = await _context.SiteSettings + .FirstOrDefaultAsync(s => s.Key == key); + + if (existing != null) + { + existing.Value = value; + existing.UpdatedAt = DateTime.UtcNow; + _context.SiteSettings.Update(existing); + } + else + { + var newSetting = new SiteSettings + { + Key = key, + Value = value, + CreatedAt = DateTime.UtcNow + }; + _context.SiteSettings.Add(newSetting); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Setting '{Key}' saved successfully", key); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save setting '{Key}'", key); + throw; + } + } + } diff --git a/Lagerbestand/Services/StorageLocationService.cs b/Lagerbestand/Services/StorageLocationService.cs new file mode 100644 index 0000000..3a72b33 --- /dev/null +++ b/Lagerbestand/Services/StorageLocationService.cs @@ -0,0 +1,447 @@ +using Lagerbestand.Data; +using Lagerbestand.Exceptions; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +/// +/// Statistics about storage location occupancy. +/// +public record OccupancyStatistics( + int TotalLocations, + int OccupiedLocations, + int AvailableLocations, + int BlockedLocations, + int TotalByLager, + int OccupiedByLager, + int TotalByWerkstatt, + int OccupiedByWerkstatt, + Dictionary LocationsByRow, + Dictionary OccupiedByRow +); + +public interface IStorageLocationService +{ + Task> GetAllAsync(bool includeInactive = false); + Task GetByIdAsync(Guid id); + Task GetByCodeAsync(string code); + Task> GetAvailableAsync(); + Task> GetByAreaAsync(AreaType area); + Task> GetByRowAsync(AreaType area, int row); + Task CreateAsync(StorageLocation location); + Task UpdateAsync(StorageLocation location); + Task DeleteAsync(Guid id); + Task> GetAreasAsync(); + Task> GetRowsAsync(AreaType area); + Task IsLocationAvailableAsync(Guid locationId); + Task GetOccupancyAsync(Guid locationId); + Task AssignTireStackAsync(string locationCode, Guid tireStackId); + Task ClearLocationAsync(string locationCode); + Task MoveStackAsync(string fromCode, string toCode); + Task GetOccupancyStatisticsAsync(); + Task> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot); + + /// + /// Ensures all default storage locations exist (Werkstatt: 3 rows, Lager: 5 rows, + /// each row 9 slots × 4 positions = 288 total). Creates missing, returns number created. + /// + Task SeedDefaultLocationsAsync(); +} + +public class StorageLocationService : IStorageLocationService +{ + private readonly AppDbContext _context; + private readonly ILocationCodeService _locationCodeService; + private readonly ILogger _logger; + + public StorageLocationService( + AppDbContext context, + ILocationCodeService locationCodeService, + ILogger logger) + { + _context = context; + _locationCodeService = locationCodeService; + _logger = logger; + } + + public async Task> GetAllAsync(bool includeInactive = false) + { + var query = _context.StorageLocations + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.Customer) + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.CatalogItem) + .AsQueryable(); + + if (!includeInactive) + query = query.Where(s => s.IsActive); + + return await query + .OrderBy(s => s.Area) + .ThenBy(s => s.Row) + .ThenBy(s => s.SlotNumber) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.StorageLocations + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.Customer) + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.CatalogItem) + .FirstOrDefaultAsync(s => s.Id == id); + } + + public async Task GetByCodeAsync(string code) + { + return await _context.StorageLocations + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.Customer) + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.CatalogItem) + .FirstOrDefaultAsync(s => s.Code == code); + } + + public async Task> GetAvailableAsync() + { + return await _context.StorageLocations + .AsNoTracking() + .Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied) + .OrderBy(s => s.Area) + .ThenBy(s => s.Row) + .ThenBy(s => s.SlotNumber) + .ToListAsync(); + } + + public async Task> GetByAreaAsync(AreaType area) + { + return await _context.StorageLocations + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.Customer) + .Where(s => s.IsActive && s.Area == area) + .OrderBy(s => s.Row) + .ThenBy(s => s.SlotNumber) + .ToListAsync(); + } + + public async Task> GetByRowAsync(AreaType area, int row) + { + return await _context.StorageLocations + .Include(s => s.OccupiedTireStack) + .ThenInclude(ts => ts!.Customer) + .Where(s => s.IsActive && s.Area == area && s.Row == row) + .OrderBy(s => s.SlotNumber) + .ToListAsync(); + } + + public async Task CreateAsync(StorageLocation location) + { + // Ensure code is generated from components + location.GenerateCode(); + + // Validate the generated code + var validation = _locationCodeService.ValidateLocationCode(location.Code); + if (!validation.IsValid) + throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!); + + // Check for duplicate code + if (await _locationCodeService.CodeExistsAsync(location.Code)) + throw new LocationCodeExistsException(location.Code); + + location.CreatedAt = DateTime.UtcNow; + + _context.StorageLocations.Add(location); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Storage location created: {Code} ({Display})", + location.Code, location.DisplayName); + + return location; + } + + public async Task UpdateAsync(StorageLocation location) + { + // Regenerate code from components + location.GenerateCode(); + + // Validate the code + var validation = _locationCodeService.ValidateLocationCode(location.Code); + if (!validation.IsValid) + throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!); + + // Check for duplicate code (excluding this location) + var existingWithCode = await _context.StorageLocations + .FirstOrDefaultAsync(l => l.Code == location.Code && l.Id != location.Id); + if (existingWithCode != null) + throw new LocationCodeExistsException(location.Code); + + location.UpdatedAt = DateTime.UtcNow; + _context.StorageLocations.Update(location); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Storage location updated: {Code}", location.Code); + + return location; + } + + public async Task DeleteAsync(Guid id) + { + var location = await _context.StorageLocations + .FirstOrDefaultAsync(s => s.Id == id); + + if (location == null) + return false; + + // Cannot delete occupied location + if (location.IsOccupied) + { + _logger.LogWarning("Cannot delete storage location {Code} - is occupied", location.Code); + return false; + } + + // Soft delete + location.IsActive = false; + location.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Storage location deactivated: {Code}", location.Code); + + return true; + } + + public async Task> GetAreasAsync() + { + var areas = await _context.StorageLocations + .Where(s => s.IsActive) + .Select(s => s.Area) + .Distinct() + .ToListAsync(); + + return areas.OrderBy(a => (int)a).ToList(); + } + + public async Task> GetRowsAsync(AreaType area) + { + return await _context.StorageLocations + .Where(s => s.IsActive && s.Area == area) + .Select(s => s.Row) + .Distinct() + .OrderBy(r => r) + .ToListAsync(); + } + + public async Task IsLocationAvailableAsync(Guid locationId) + { + var location = await _context.StorageLocations + .FirstOrDefaultAsync(s => s.Id == locationId); + + if (location == null || !location.IsActive || !location.IsAvailable) + return false; + + return !location.IsOccupied; + } + + public async Task GetOccupancyAsync(Guid locationId) + { + var location = await _context.StorageLocations + .FirstOrDefaultAsync(s => s.Id == locationId); + + return location?.IsOccupied == true ? 1 : 0; + } + + public async Task AssignTireStackAsync(string locationCode, Guid tireStackId) + { + var location = await GetByCodeAsync(locationCode) + ?? throw new LocationNotFoundException(locationCode); + + if (location.IsOccupied) + throw new LocationOccupiedException(locationCode, location.TireStackId!.Value); + + if (!location.IsAvailable) + throw new InvalidOperationException($"Lagerplatz {locationCode} ist gesperrt."); + + location.AssignTireStack(tireStackId); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Tire stack {TireStackId} assigned to location {Code}", + tireStackId, locationCode); + } + + public async Task ClearLocationAsync(string locationCode) + { + var location = await GetByCodeAsync(locationCode) + ?? throw new LocationNotFoundException(locationCode); + + var previousTireStackId = location.TireStackId; + location.ClearLocation(); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Location {Code} cleared (previous tire stack: {TireStackId})", + locationCode, previousTireStackId); + } + + public async Task MoveStackAsync(string fromCode, string toCode) + { + var fromLocation = await GetByCodeAsync(fromCode) + ?? throw new LocationNotFoundException(fromCode); + + var toLocation = await GetByCodeAsync(toCode) + ?? throw new LocationNotFoundException(toCode); + + if (!fromLocation.IsOccupied) + throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt."); + + if (toLocation.IsOccupied) + throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value); + + if (!toLocation.IsAvailable) + throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt."); + + var tireStackId = fromLocation.TireStackId!.Value; + + // Update the tire stack's storage location reference + var tireStack = await _context.TireStacks.FindAsync(tireStackId); + if (tireStack != null) + { + tireStack.StorageLocationId = toLocation.Id; + tireStack.UpdatedAt = DateTime.UtcNow; + } + + // Clear source and assign to destination + fromLocation.ClearLocation(); + toLocation.AssignTireStack(tireStackId); + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Tire stack {TireStackId} moved from {FromCode} to {ToCode}", + tireStackId, fromCode, toCode); + } + + public async Task GetOccupancyStatisticsAsync() + { + var locations = await _context.StorageLocations + .Where(l => l.IsActive) + .ToListAsync(); + + var locationsByRow = locations + .GroupBy(l => l.Row) + .ToDictionary(g => g.Key, g => g.Count()); + + var occupiedByRow = locations + .Where(l => l.IsOccupied) + .GroupBy(l => l.Row) + .ToDictionary(g => g.Key, g => g.Count()); + + return new OccupancyStatistics( + TotalLocations: locations.Count, + OccupiedLocations: locations.Count(l => l.IsOccupied), + AvailableLocations: locations.Count(l => !l.IsOccupied && l.IsAvailable), + BlockedLocations: locations.Count(l => !l.IsAvailable), + TotalByLager: locations.Count(l => l.Area == AreaType.Lager), + OccupiedByLager: locations.Count(l => l.Area == AreaType.Lager && l.IsOccupied), + TotalByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt), + OccupiedByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt && l.IsOccupied), + LocationsByRow: locationsByRow, + OccupiedByRow: occupiedByRow + ); + } + + public async Task> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot) + { + var maxRow = area.GetMaxRow(); + if (row < StorageLocation.MinRow || row > maxRow) + throw new ArgumentOutOfRangeException(nameof(row), + $"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen."); + + if (fromSlot < StorageLocation.MinSlot || toSlot > StorageLocation.MaxSlot || fromSlot > toSlot) + throw new ArgumentOutOfRangeException(nameof(fromSlot), + $"Plätze müssen zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen."); + + var existingCodes = await _context.StorageLocations + .Where(l => l.Area == area && l.Row == row) + .Select(l => l.Code) + .ToListAsync(); + var existingSet = existingCodes.ToHashSet(); + + var createdLocations = new List(); + + for (int slot = fromSlot; slot <= toSlot; slot++) + { + foreach (var position in ShelfPositionExtensions.All) + { + var location = new StorageLocation + { + Area = area, + Row = row, + Position = position, + SlotNumber = slot + }; + location.GenerateCode(); + + if (existingSet.Contains(location.Code)) + { + _logger.LogDebug("Skipping existing location: {Code}", location.Code); + continue; + } + + _context.StorageLocations.Add(location); + createdLocations.Add(location); + } + } + + if (createdLocations.Count > 0) + { + await _context.SaveChangesAsync(); + _logger.LogInformation("Created {Count} storage locations in {Area} Row {Row}", + createdLocations.Count, area.GetDisplayName(), row); + } + + return createdLocations; + } + + public async Task SeedDefaultLocationsAsync() + { + var existingCodes = (await _context.StorageLocations + .Select(l => l.Code) + .ToListAsync()).ToHashSet(); + + var created = 0; + + foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager }) + { + for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++) + { + foreach (var position in ShelfPositionExtensions.All) + { + for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++) + { + var location = new StorageLocation + { + Area = area, + Row = row, + Position = position, + SlotNumber = slot + }; + location.GenerateCode(); + + if (existingCodes.Contains(location.Code)) + continue; + + _context.StorageLocations.Add(location); + created++; + } + } + } + } + + if (created > 0) + { + await _context.SaveChangesAsync(); + _logger.LogInformation("Seeded {Count} default storage locations", created); + } + + return created; + } +} diff --git a/Lagerbestand/Services/TireStackService.cs b/Lagerbestand/Services/TireStackService.cs new file mode 100644 index 0000000..e3b679d --- /dev/null +++ b/Lagerbestand/Services/TireStackService.cs @@ -0,0 +1,504 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +/// +/// Service interface for managing tire stacks +/// +public interface ITireStackService +{ + Task> GetAllAsync(TireStackStatus? status = null); + Task> GetAllAsync(TireStackStatus? status, bool? isInventory); + Task GetByIdAsync(Guid id); + Task GetByTicketNumberAsync(string ticketNumber); + Task> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null); + Task> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null); + Task> GetByStorageLocationAsync(Guid locationId); + Task> SearchAsync(string searchTerm); + Task CreateAsync(TireStack tireStack); + Task UpdateAsync(TireStack tireStack); + Task StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null); + Task CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null); + Task PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null); + Task> PickupBatchAsync(IEnumerable tireStackIds, Guid? pickedUpByUserId = null); + Task DeleteAsync(Guid id); + Task GenerateTicketNumberAsync(bool isInventory = false); + Task GetStatisticsAsync(); + Task> GetExpiringPickupsAsync(int daysAhead = 14); +} + +/// +/// Statistics for the tire storage system +/// +public class StorageStatistics +{ + /// + /// Total number of stacks currently stored + /// + public int TotalStacksStored { get; set; } + + /// + /// Total number of individual tires stored (stacks * 4) + /// + public int TotalTiresStored => TotalStacksStored * TireStack.TiresPerStack; + + /// + /// Total stacks picked up (historical) + /// + public int TotalStacksPickedUp { get; set; } + + /// + /// Number of active customers + /// + public int TotalCustomers { get; set; } + + /// + /// Number of available storage locations + /// + public int AvailableLocations { get; set; } + + /// + /// Number of occupied storage locations + /// + public int OccupiedLocations { get; set; } + + /// + /// Summer tire stacks stored + /// + public int SummerStacksStored { get; set; } + + /// + /// Winter tire stacks stored + /// + public int WinterStacksStored { get; set; } + + /// + /// All-season tire stacks stored + /// + public int AllSeasonStacksStored { get; set; } + + /// + /// Inventory stacks (shop stock) + /// + public int InventoryStackCount { get; set; } + + /// + /// Customer tire stacks + /// + public int CustomerStackCount { get; set; } +} + +/// +/// Service implementation for managing tire stacks +/// +public class TireStackService : ITireStackService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public TireStackService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task> GetAllAsync(TireStackStatus? status = null) + { + return await GetAllAsync(status, null); + } + + public async Task> GetAllAsync(TireStackStatus? status, bool? isInventory) + { + var query = _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.Vehicle) + .Include(ts => ts.StorageLocation) + .Include(ts => ts.CatalogItem) + .Where(ts => ts.IsActive); + + if (status.HasValue) + query = query.Where(ts => ts.Status == status.Value); + + if (isInventory.HasValue) + { + if (isInventory.Value) + query = query.Where(ts => ts.CustomerId == null); + else + query = query.Where(ts => ts.CustomerId != null); + } + + return await query + .OrderByDescending(ts => ts.StoredAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.Vehicle) + .Include(ts => ts.StorageLocation) + .Include(ts => ts.CatalogItem) + .FirstOrDefaultAsync(ts => ts.Id == id && ts.IsActive); + } + + public async Task GetByTicketNumberAsync(string ticketNumber) + { + return await _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.Vehicle) + .Include(ts => ts.StorageLocation) + .Include(ts => ts.CatalogItem) + .FirstOrDefaultAsync(ts => ts.TicketNumber == ticketNumber && ts.IsActive); + } + + public async Task> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null) + { + var query = _context.TireStacks + .Include(ts => ts.StorageLocation) + .Include(ts => ts.Vehicle) + .Include(ts => ts.CatalogItem) + .Where(ts => ts.CustomerId == customerId && ts.IsActive); + + if (status.HasValue) + query = query.Where(ts => ts.Status == status.Value); + + return await query + .OrderByDescending(ts => ts.StoredAt) + .ToListAsync(); + } + + public async Task> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null) + { + var query = _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.Vehicle) + .Include(ts => ts.StorageLocation) + .Include(ts => ts.CatalogItem) + .Where(ts => ts.CatalogItemId == catalogItemId && ts.IsActive); + + if (status.HasValue) + query = query.Where(ts => ts.Status == status.Value); + + return await query + .OrderByDescending(ts => ts.StoredAt) + .ToListAsync(); + } + + public async Task> GetByStorageLocationAsync(Guid locationId) + { + return await _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.Vehicle) + .Include(ts => ts.CatalogItem) + .Where(ts => ts.StorageLocationId == locationId && ts.Status == TireStackStatus.Stored && ts.IsActive) + .OrderBy(ts => ts.StoredAt) + .ToListAsync(); + } + + public async Task> SearchAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return await GetAllAsync(TireStackStatus.Stored); + + var term = searchTerm.Trim().ToLower(); + + return await _context.TireStacks + .AsNoTracking() + .Include(ts => ts.Customer) + .Include(ts => ts.Vehicle) + .Include(ts => ts.StorageLocation) + .Include(ts => ts.CatalogItem) + .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ( + ts.TicketNumber.ToLower().Contains(term) || + (ts.TireBrand != null && ts.TireBrand.ToLower().Contains(term)) || + (ts.TireSize != null && ts.TireSize.ToLower().Contains(term)) || + (ts.Customer != null && ts.Customer.FirstName != null && ts.Customer.FirstName.ToLower().Contains(term)) || + (ts.Customer != null && ts.Customer.LastName.ToLower().Contains(term)) || + (ts.Customer != null && ts.Customer.CustomerNumber.ToLower().Contains(term)) || + (ts.Vehicle != null && ts.Vehicle.Make.ToLower().Contains(term)) || + (ts.Vehicle != null && ts.Vehicle.Model != null && ts.Vehicle.Model.ToLower().Contains(term)) || + (ts.StorageLocation != null && ts.StorageLocation.Code.ToLower().Contains(term)) || + (ts.CatalogItem != null && ts.CatalogItem.Name.ToLower().Contains(term)) + )) + .OrderByDescending(ts => ts.StoredAt) + .Take(50) + .ToListAsync(); + } + + public async Task CreateAsync(TireStack tireStack) + { + var isInventory = tireStack.CustomerId == null; + + if (string.IsNullOrEmpty(tireStack.TicketNumber)) + tireStack.TicketNumber = await GenerateTicketNumberAsync(isInventory); + + tireStack.CreatedAt = DateTime.UtcNow; + tireStack.StoredAt = DateTime.UtcNow; + + _context.TireStacks.Add(tireStack); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Tire stack created: {TicketNumber}, IsInventory: {IsInventory}", + tireStack.TicketNumber, isInventory); + + return tireStack; + } + + public async Task UpdateAsync(TireStack tireStack) + { + tireStack.UpdatedAt = DateTime.UtcNow; + _context.TireStacks.Update(tireStack); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Tire stack updated: {TicketNumber}", tireStack.TicketNumber); + + return tireStack; + } + + public async Task StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null) + { + // Verify storage location is available (force fresh values in case a + // stale tracked instance exists in this long-lived scoped context) + var location = await _context.StorageLocations + .FirstOrDefaultAsync(s => s.Id == storageLocationId); + + if (location == null) + throw new InvalidOperationException("Lagerplatz nicht gefunden."); + + await _context.Entry(location).ReloadAsync(); + + if (!location.IsActive || !location.IsAvailable) + throw new InvalidOperationException("Lagerplatz ist nicht verfügbar."); + + // AssignTireStack handles the IsOccupied check and sets TireStackId/IsOccupied/OccupiedSince + location.AssignTireStack(tireStack.Id); + + tireStack.StorageLocationId = storageLocationId; + tireStack.Status = TireStackStatus.Stored; + tireStack.StoredAt = DateTime.UtcNow; + tireStack.StoredByUserId = storedByUserId; + tireStack.UpdatedAt = DateTime.UtcNow; + + _context.TireStacks.Update(tireStack); + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + throw new InvalidOperationException($"Lagerplatz {location.Code} wurde soeben anderweitig belegt."); + } + + _logger.LogInformation("Tire stack {TicketNumber} stored at location {LocationCode}", + tireStack.TicketNumber, location.Code); + + return tireStack; + } + + public async Task PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null) + { + var tireStack = await _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.StorageLocation) + .FirstOrDefaultAsync(ts => ts.Id == tireStackId); + + if (tireStack == null) + throw new InvalidOperationException("Reifenstapel nicht gefunden."); + + if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved) + throw new InvalidOperationException("Reifenstapel ist nicht eingelagert."); + + // Clear the storage location (both sides) + if (tireStack.StorageLocation != null) + tireStack.StorageLocation.ClearLocation(); + + tireStack.Status = TireStackStatus.PickedUp; + tireStack.PickedUpAt = DateTime.UtcNow; + tireStack.PickedUpByUserId = pickedUpByUserId; + tireStack.StorageLocationId = null; + tireStack.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Tire stack {TicketNumber} picked up", tireStack.TicketNumber); + + return tireStack; + } + + public async Task CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null) + { + // MySqlRetryingExecutionStrategy requires user transactions to run + // inside the execution strategy as a single retriable unit. + var hadTicketNumber = !string.IsNullOrEmpty(tireStack.TicketNumber); + var strategy = _context.Database.CreateExecutionStrategy(); + try + { + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + var created = await CreateAsync(tireStack); + var stored = await StoreAsync(created, storageLocationId, storedByUserId); + await transaction.CommitAsync(); + return stored; + } + catch + { + await transaction.RollbackAsync(); + // Detach the failed entities so a retry (by the strategy or + // the user) does not re-insert stale Added/Modified state. + _context.ChangeTracker.Clear(); + // Regenerate the ticket number on retry if we created it here + // (avoids permanent unique-index collisions). + if (!hadTicketNumber) + tireStack.TicketNumber = string.Empty; + throw; + } + }); + } + catch (DbUpdateConcurrencyException) + { + throw new InvalidOperationException("Der Lagerplatz wurde soeben anderweitig belegt. Bitte anderen Platz wählen."); + } + } + + public async Task> PickupBatchAsync(IEnumerable tireStackIds, Guid? pickedUpByUserId = null) + { + var ids = tireStackIds.Distinct().ToList(); + if (ids.Count == 0) + return new List(); + + var strategy = _context.Database.CreateExecutionStrategy(); + try + { + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + var pickedUp = new List(ids.Count); + foreach (var id in ids) + { + pickedUp.Add(await PickupAsync(id, pickedUpByUserId)); + } + await transaction.CommitAsync(); + return pickedUp; + } + catch + { + await transaction.RollbackAsync(); + _context.ChangeTracker.Clear(); + throw; + } + }); + } + catch (DbUpdateConcurrencyException) + { + throw new InvalidOperationException("Ein Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen."); + } + } + + public async Task DeleteAsync(Guid id) + { + var tireStack = await _context.TireStacks.FindAsync(id); + if (tireStack == null) + return false; + + // Block deletion of stored or reserved tire stacks + if (tireStack.Status == TireStackStatus.Stored || tireStack.Status == TireStackStatus.Reserved) + { + _logger.LogWarning("Cannot delete {Status} tire stack {TicketNumber}", tireStack.Status, tireStack.TicketNumber); + return false; + } + + // Soft delete + tireStack.IsActive = false; + tireStack.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Tire stack soft-deleted: {TicketNumber}", tireStack.TicketNumber); + + return true; + } + + public async Task GenerateTicketNumberAsync(bool isInventory = false) + { + var year = DateTime.UtcNow.Year; + var prefix = isInventory ? $"INV-{year}-" : $"TS-{year}-"; + + var lastNumber = await _context.TireStacks + .Where(ts => ts.TicketNumber.StartsWith(prefix)) + .OrderByDescending(ts => ts.TicketNumber) + .Select(ts => ts.TicketNumber) + .FirstOrDefaultAsync(); + + var nextNumber = 1; + if (lastNumber != null) + { + var numPart = lastNumber.Replace(prefix, ""); + if (int.TryParse(numPart, out var lastNum)) + nextNumber = lastNum + 1; + } + + return $"{prefix}{nextNumber:D5}"; + } + + public async Task GetStatisticsAsync() + { + var storedStacks = await _context.TireStacks + .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored) + .GroupBy(ts => ts.Season) + .Select(g => new { Season = g.Key, Count = g.Count() }) + .ToListAsync(); + + var totalLocations = await _context.StorageLocations + .Where(s => s.IsActive) + .CountAsync(); + + var occupiedLocations = await _context.TireStacks + .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.StorageLocationId != null) + .Select(ts => ts.StorageLocationId) + .Distinct() + .CountAsync(); + + var inventoryCount = await _context.TireStacks + .CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId == null); + + var customerStackCount = await _context.TireStacks + .CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId != null); + + return new StorageStatistics + { + TotalStacksStored = storedStacks.Sum(x => x.Count), + TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp), + TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive), + AvailableLocations = totalLocations - occupiedLocations, + OccupiedLocations = occupiedLocations, + SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0, + WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0, + AllSeasonStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Allrad)?.Count ?? 0, + InventoryStackCount = inventoryCount, + CustomerStackCount = customerStackCount + }; + } + + public async Task> GetExpiringPickupsAsync(int daysAhead = 14) + { + var cutoffDate = DateTime.UtcNow.AddDays(daysAhead); + + return await _context.TireStacks + .Include(ts => ts.Customer) + .Include(ts => ts.StorageLocation) + .Where(ts => ts.IsActive && + ts.Status == TireStackStatus.Stored && + ts.CustomerId != null && + ts.ExpectedPickupDate != null && + ts.ExpectedPickupDate <= cutoffDate) + .OrderBy(ts => ts.ExpectedPickupDate) + .ToListAsync(); + } +} diff --git a/Lagerbestand/Services/TwoFactorService.cs b/Lagerbestand/Services/TwoFactorService.cs new file mode 100644 index 0000000..885d859 --- /dev/null +++ b/Lagerbestand/Services/TwoFactorService.cs @@ -0,0 +1,243 @@ +using System.Net; +using System.Net.Mail; +using System.Security.Cryptography; +using System.Text; +using Lagerbestand.Models; +using QRCoder; + +namespace Lagerbestand.Services; + +public interface ITwoFactorService +{ + /// + /// Generates a new random Base32-encoded TOTP secret key. + /// + string GenerateSecretKey(); + + /// + /// Generates the otpauth:// URI for authenticator app setup. + /// + string GenerateOtpAuthUri(string secret, string username, string issuer); + + /// + /// Validates a 6-digit TOTP code against a secret (±1 time step tolerance). + /// + bool ValidateTotpCode(string secret, string code); + + /// + /// Generates a random 6-digit email verification code. + /// + string GenerateEmailCode(); + + /// + /// Sends a 2FA code via email using configured SMTP settings. + /// + Task SendEmailCodeAsync(string toEmail, string code, string username); + + /// + /// Generates a QR code as a base64 PNG data URL from an otpauth URI. + /// + string GenerateQrCodeDataUrl(string otpAuthUri); +} + +public class TwoFactorService : ITwoFactorService +{ + private const int SecretKeyLength = 20; // 160 bits + private const int TotpCodeLength = 6; + private const int TotpTimeStepSeconds = 30; + private const int TotpTimeTolerance = 1; // ±1 step (30 seconds each side) + + private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray(); + + private readonly ISiteSettingsService _siteSettingsService; + private readonly ILogger _logger; + + public TwoFactorService(ISiteSettingsService siteSettingsService, ILogger logger) + { + _siteSettingsService = siteSettingsService; + _logger = logger; + } + + public string GenerateSecretKey() + { + var secretBytes = RandomNumberGenerator.GetBytes(SecretKeyLength); + return Base32Encode(secretBytes); + } + + public string GenerateOtpAuthUri(string secret, string username, string issuer) + { + var encodedIssuer = Uri.EscapeDataString(issuer); + var encodedUsername = Uri.EscapeDataString(username); + return $"otpauth://totp/{encodedIssuer}:{encodedUsername}?secret={secret}&issuer={encodedIssuer}&digits={TotpCodeLength}&period={TotpTimeStepSeconds}"; + } + + public bool ValidateTotpCode(string secret, string code) + { + if (string.IsNullOrWhiteSpace(code) || code.Length != TotpCodeLength) + return false; + + var secretBytes = Base32Decode(secret); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // Check current time step and ±tolerance steps + for (int i = -TotpTimeTolerance; i <= TotpTimeTolerance; i++) + { + var timeStep = (unixTimestamp / TotpTimeStepSeconds) + i; + var generatedCode = ComputeTotp(secretBytes, timeStep); + if (CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(generatedCode), + Encoding.UTF8.GetBytes(code))) + { + return true; + } + } + + return false; + } + + public string GenerateEmailCode() + { + return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + } + + public async Task SendEmailCodeAsync(string toEmail, string code, string username) + { + try + { + var smtpSettings = await _siteSettingsService.GetSmtpSettingsAsync(); + if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured) + { + _logger.LogWarning("SMTP is not configured. Cannot send 2FA email to {Email}", toEmail); + return false; + } + + using var client = new SmtpClient(smtpSettings.Host, smtpSettings.Port) + { + Credentials = new NetworkCredential(smtpSettings.Username, smtpSettings.Password), + EnableSsl = smtpSettings.UseSsl, + Timeout = 15_000 + }; + + using var message = new MailMessage + { + From = new MailAddress(smtpSettings.FromEmail, smtpSettings.FromName), + Subject = $"Ihr Anmeldecode – {code}", + IsBodyHtml = true, + Body = $""" +
+

Reifen Späth – Anmeldecode

+

Hallo {username},

+

Ihr Einmal-Code für die Anmeldung lautet:

+
+ {code} +
+

Dieser Code ist 5 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.

+
+ """ + }; + message.To.Add(toEmail); + + await client.SendMailAsync(message); + _logger.LogInformation("2FA email code sent to {Email} for user {Username}", toEmail, username); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send 2FA email to {Email}", toEmail); + return false; + } + } + + public string GenerateQrCodeDataUrl(string otpAuthUri) + { + using var qrGenerator = new QRCodeGenerator(); + using var qrCodeData = qrGenerator.CreateQrCode(otpAuthUri, QRCodeGenerator.ECCLevel.M); + using var qrCode = new PngByteQRCode(qrCodeData); + var pngBytes = qrCode.GetGraphic(8, new byte[] { 30, 30, 32 }, new byte[] { 255, 255, 255 }); + return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}"; + } + + #region TOTP Internals + + private static string ComputeTotp(byte[] secret, long timeStep) + { + var counterBytes = BitConverter.GetBytes(timeStep); + if (BitConverter.IsLittleEndian) + Array.Reverse(counterBytes); + + var hash = HMACSHA1.HashData(secret, counterBytes); + + var offset = hash[^1] & 0x0F; + var code = ((hash[offset] & 0x7F) << 24) | + ((hash[offset + 1] & 0xFF) << 16) | + ((hash[offset + 2] & 0xFF) << 8) | + (hash[offset + 3] & 0xFF); + + return (code % 1_000_000).ToString("D6"); + } + + #endregion + + #region Base32 + + private static string Base32Encode(byte[] data) + { + var sb = new StringBuilder((data.Length * 8 + 4) / 5); + int buffer = data[0]; + int next = 1; + int bitsLeft = 8; + + while (bitsLeft > 0 || next < data.Length) + { + if (bitsLeft < 5) + { + if (next < data.Length) + { + buffer <<= 8; + buffer |= data[next++] & 0xFF; + bitsLeft += 8; + } + else + { + int pad = 5 - bitsLeft; + buffer <<= pad; + bitsLeft += pad; + } + } + int index = 0x1F & (buffer >> (bitsLeft - 5)); + bitsLeft -= 5; + sb.Append(Base32Chars[index]); + } + + return sb.ToString(); + } + + private static byte[] Base32Decode(string base32) + { + var cleanInput = base32.TrimEnd('=').ToUpperInvariant(); + var output = new byte[cleanInput.Length * 5 / 8]; + int buffer = 0; + int bitsLeft = 0; + int outputIndex = 0; + + foreach (var c in cleanInput) + { + var val = Array.IndexOf(Base32Chars, c); + if (val < 0) continue; + + buffer <<= 5; + buffer |= val & 0x1F; + bitsLeft += 5; + + if (bitsLeft >= 8) + { + output[outputIndex++] = (byte)(buffer >> (bitsLeft - 8)); + bitsLeft -= 8; + } + } + + return output; + } + + #endregion +} diff --git a/Lagerbestand/Services/UserSessionService.cs b/Lagerbestand/Services/UserSessionService.cs new file mode 100644 index 0000000..4d43a95 --- /dev/null +++ b/Lagerbestand/Services/UserSessionService.cs @@ -0,0 +1,113 @@ +using Lagerbestand.Models; + +namespace Lagerbestand.Services; + +/// +/// Simple session service to hold current user state. +/// Registered as Scoped - one instance per circuit/connection. +/// +public class UserSessionService +{ + public AppUser? CurrentUser { get; private set; } + + public bool IsAuthenticated => CurrentUser != null && CurrentUser.IsActive; + + public bool IsAdmin => CurrentUser?.Role == UserRole.Admin; + + /// + /// Worker role - has access to tire operations (checkin/checkout) and inventory list only + /// + public bool IsWorker => CurrentUser?.Role == UserRole.Worker; + + /// + /// Check if user is Worker or Admin (for tire operations access) + /// + public bool IsWorkerOrAdmin => CurrentUser?.Role == UserRole.Admin || CurrentUser?.Role == UserRole.Worker; + + public string DisplayName => CurrentUser?.DisplayName ?? CurrentUser?.Username ?? string.Empty; + + public string Username => CurrentUser?.Username ?? string.Empty; + + public Guid? UserId => CurrentUser?.Id; + + public UserRole? Role => CurrentUser?.Role; + + public string RoleName => CurrentUser?.Role switch + { + UserRole.Admin => "Administrator", + UserRole.Worker => "Mitarbeiter", + UserRole.User => "Benutzer", + _ => string.Empty + }; + + public event Action? OnChange; + + public void SetUser(AppUser user) + { + CurrentUser = user; + NotifyStateChanged(); + } + + public void ClearUser() + { + CurrentUser = null; + NotifyStateChanged(); + } + + /// + /// Updates the current user data without triggering OnChange. + /// Useful for refreshing user data after profile updates. + /// + public void UpdateUser(AppUser user) + { + if (CurrentUser?.Id == user.Id) + { + CurrentUser = user; + NotifyStateChanged(); + } + } + + /// + /// Checks if the current user has at least the specified role level. + /// Admin > Worker > User + /// + public bool HasMinimumRole(UserRole minimumRole) + { + if (CurrentUser == null || !CurrentUser.IsActive) + return false; + + return (int)CurrentUser.Role >= (int)minimumRole; + } + + /// + /// Checks if the current user can manage the specified user. + /// Users can only be managed by admins or by themselves for limited operations. + /// + public bool CanManageUser(Guid targetUserId) + { + if (!IsAuthenticated) + return false; + + // Admins can manage everyone + if (IsAdmin) + return true; + + // Users can only manage themselves (limited) + return CurrentUser?.Id == targetUserId; + } + + /// + /// Checks if the current user can delete the specified user. + /// Only admins can delete users, and they cannot delete themselves. + /// + public bool CanDeleteUser(Guid targetUserId) + { + if (!IsAdmin) + return false; + + // Admins cannot delete themselves + return CurrentUser?.Id != targetUserId; + } + + private void NotifyStateChanged() => OnChange?.Invoke(); +} diff --git a/Lagerbestand/appsettings.Development.json b/Lagerbestand/appsettings.Development.json new file mode 100644 index 0000000..72bfd09 --- /dev/null +++ b/Lagerbestand/appsettings.Development.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=10.10.10.50;Port=3306;Database=test;User=root;Password=DDsm4$n3;Connect Timeout=30;Default Command Timeout=60;Pooling=true;Min Pool Size=0;Max Pool Size=100;Connection Lifetime=0;" + }, + "AdminUser": { + "Username": "spaethf", + "Password": "DDsm4$n3", + "DisplayName": "Administrator" + } +} diff --git a/Lagerbestand/appsettings.Production.json b/Lagerbestand/appsettings.Production.json new file mode 100644 index 0000000..5aa962a --- /dev/null +++ b/Lagerbestand/appsettings.Production.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:5000" + } + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=db.spaeth-bayern.de;Port=3306;Database=lagerbestand;User=root;Password=DDsm4$n3;Connect Timeout=60;Default Command Timeout=120;Pooling=true;Min Pool Size=0;Max Pool Size=100;Connection Lifetime=0;SslMode=Preferred;" + } +} diff --git a/Lagerbestand/appsettings.json b/Lagerbestand/appsettings.json new file mode 100644 index 0000000..8d807d4 --- /dev/null +++ b/Lagerbestand/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Server=db.spaeth-bayern.de;Port=3306;Database=lagerbestand;User=root;Password=DDsm4$n3;Connect Timeout=30;Default Command Timeout=60;Pooling=true;Min Pool Size=0;Max Pool Size=100;Connection Lifetime=0;" + }, + "AdminUser": { + "Username": "spaethf", + "Password": "DDsm4$n3", + "DisplayName": "Administrator" + } +} diff --git a/Lagerbestand/wwwroot/app.css b/Lagerbestand/wwwroot/app.css new file mode 100644 index 0000000..1cc7c30 --- /dev/null +++ b/Lagerbestand/wwwroot/app.css @@ -0,0 +1,1484 @@ +/* + * Reifen Späth - Lagerbestand App + * Design System - Single Source of Truth + * ============================================= + */ + +/* ============================================= + 1. CSS Variables / Design Tokens + ============================================= */ +:root { + /* Brand Colors */ + --color-primary: #dc2626; + --color-primary-light: #ef4444; + --color-primary-dark: #b91c1c; + --color-secondary: #c0c0c0; + + /* Semantic Colors */ + --color-success: #22c55e; + --color-success-light: #86efac; + --color-warning: #fbbf24; + --color-warning-light: #fde047; + --color-error: #ef4444; + --color-error-light: #f87171; + --color-info: #60a5fa; + --color-info-light: #93c5fd; + + /* Background Colors */ + --color-bg: #000000; + --color-bg-gradient: linear-gradient(180deg, #000000 0%, #0a0a0a 100%); + + /* Surface Colors */ + --surface-primary: rgba(28, 28, 30, 0.95); + --surface-secondary: rgba(44, 44, 46, 0.9); + --surface-tertiary: rgba(58, 58, 60, 0.8); + --surface-elevated: rgba(28, 28, 30, 0.8); + + /* Text Colors */ + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.7); + --text-tertiary: rgba(255, 255, 255, 0.5); + --text-muted: rgba(255, 255, 255, 0.5); + --text-placeholder: rgba(255, 255, 255, 0.4); + + /* Border Colors */ + --border-default: rgba(255, 255, 255, 0.1); + --border-light: rgba(255, 255, 255, 0.06); + --border-focus: rgba(220, 38, 38, 0.5); + + /* Spacing Scale (4px base) */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + + /* Responsive spacing */ + --space-page-x: clamp(0.75rem, 2vw, 2rem); + --space-page-y: clamp(1rem, 3vw, 2.5rem); + + /* Typography Scale */ + --font-family-base: 'Rajdhani', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; + --font-family-heading: 'Orbitron', 'SF Pro Display', monospace; + --font-family-mono: 'Orbitron', monospace; + --font-family-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; + + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px - minimum for inputs */ + --text-lg: 1.125rem; /* 18px */ + --text-xl: 1.25rem; /* 20px */ + --text-2xl: 1.5rem; /* 24px */ + --text-3xl: 1.875rem; /* 30px */ + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.2; + --line-height-normal: 1.5; + --line-height-relaxed: 1.6; + + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 20px rgba(220, 38, 38, 0.4); + --shadow-btn-primary: 0 4px 12px rgba(220, 38, 38, 0.3); + --shadow-btn-primary-hover: 0 6px 18px rgba(220, 38, 38, 0.4); + + /* Transitions */ + --ease-default: cubic-bezier(0.25, 0.1, 0.25, 1); + --ease-out: cubic-bezier(0.25, 1, 0.5, 1); + --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); + --duration-fast: 150ms; + --duration-normal: 250ms; + --duration-slow: 350ms; + + /* Touch Targets (Apple HIG minimum 44px) */ + --touch-target: 44px; + --touch-target-sm: 36px; + --touch-target-lg: 52px; + + /* Safe Areas */ + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + + /* Z-Index Scale — values above Bootstrap's highest (1080) */ + --z-dropdown: 100; + --z-sticky: 200; + --z-modal-backdrop: 1100; + --z-modal: 1200; + --z-tooltip: 1300; + --z-toast: 1400; + + /* Emoji sizing */ + --emoji-sm: 1rem; + --emoji-md: 1.25rem; + --emoji-lg: 1.5rem; + --emoji-xl: 2rem; +} + +/* ============================================= + 2. Base Styles / Reset + ============================================= */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; + text-size-adjust: 100%; +} + +body { + font-family: var(--font-family-base); + background: var(--color-bg-gradient); + color: var(--text-primary); + line-height: var(--line-height-normal); + min-height: 100vh; + min-height: 100dvh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overscroll-behavior-y: contain; + font-size: max(16px, 1rem); +} + +img, svg, video, iframe { + display: block; + max-width: 100%; + height: auto; +} + +a { + color: var(--color-primary-light); + text-decoration: none; + transition: color var(--duration-fast) var(--ease-default); +} + +a:hover { + color: var(--color-primary); +} + +a:active { + opacity: 0.7; +} + +/* ============================================= + 3. Typography + ============================================= */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-family-heading); + color: var(--text-primary); + line-height: var(--line-height-tight); + font-weight: var(--font-weight-bold); +} + +h1 { font-size: clamp(1.5rem, 5vw, 2.5rem); } +h2 { font-size: clamp(1.25rem, 4vw, 2rem); } +h3 { font-size: clamp(1.1rem, 3vw, 1.5rem); } +h4 { font-size: clamp(1rem, 2.5vw, 1.25rem); } + +.metallic-text { + background: linear-gradient(45deg, #c0c0c0, #ffffff, #c0c0c0); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ============================================= + 4. Form Controls + ============================================= */ +.form-control, +.form-select { + width: 100%; + min-height: var(--touch-target); + padding: var(--space-3) var(--space-4); + font-size: var(--text-base); + font-family: var(--font-family-base); + background-color: rgba(0, 0, 0, 0.3); + border: 0.5px solid var(--border-default); + border-radius: var(--radius-md); + color: var(--text-primary); + -webkit-text-fill-color: var(--text-primary); + transition: all var(--duration-fast) var(--ease-default); + -webkit-appearance: none; + appearance: none; +} + +.form-control:focus, +.form-select:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); + background-color: rgba(0, 0, 0, 0.4); + color: var(--text-primary); + -webkit-text-fill-color: var(--text-primary); +} + +.form-control:-webkit-autofill, +.form-control:-webkit-autofill:hover, +.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; +} + +.form-control::placeholder { + color: var(--text-placeholder); +} + +.form-control:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-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); +} + +textarea.form-control { + resize: vertical; + min-height: 80px; +} + +/* Select dropdown styling */ +select.form-control { + 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 var(--space-4) center; + padding-right: calc(var(--space-4) * 2 + 12px); + cursor: pointer; +} + +select.form-control option { + background: #1c1c1e; + color: var(--text-primary); +} + +/* Validation States */ +.form-control.is-valid { + border-color: rgba(34, 197, 94, 0.6); +} + +.form-control.is-invalid { + border-color: rgba(239, 68, 68, 0.6); +} + +/* Form Layout Utilities */ +.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; + } +} + +/* ============================================= + 5. Buttons + ============================================= */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: var(--touch-target); + min-width: var(--touch-target); + padding: var(--space-3) var(--space-5); + border: none; + border-radius: var(--radius-md); + font-family: var(--font-family-base); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + text-decoration: none; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-out); + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.btn:active:not(:disabled) { + transform: scale(0.97); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-primary) 100%); + color: var(--text-primary); + box-shadow: var(--shadow-btn-primary); +} + +.btn-primary:hover:not(:disabled) { + box-shadow: var(--shadow-btn-primary-hover); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--surface-secondary); + color: var(--text-primary); + border: 0.5px solid var(--border-default); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--surface-tertiary); +} + +.btn-success { + background: linear-gradient(180deg, #34d058 0%, var(--color-success) 100%); + color: var(--text-primary); + box-shadow: 0 2px 8px rgba(48, 209, 88, 0.3); +} + +.btn-danger { + background: linear-gradient(180deg, var(--color-error-light) 0%, var(--color-error) 100%); + color: var(--text-primary); + box-shadow: 0 2px 8px rgba(255, 69, 58, 0.3); +} + +.btn-outline { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-default); +} + +.btn-outline:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-outline-primary { + background: transparent; + color: var(--color-primary-light); + border: 1px solid rgba(220, 38, 38, 0.4); +} + +.btn-outline-primary:hover:not(:disabled) { + background: rgba(220, 38, 38, 0.1); + color: var(--color-primary-light); + border-color: rgba(220, 38, 38, 0.6); +} + +.btn-lg { + min-height: var(--touch-target-lg); + padding: var(--space-4) var(--space-6); + font-size: var(--text-base); +} + +.btn-sm { + min-height: var(--touch-target-sm); + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); +} + +/* ============================================= + 6. Cards + ============================================= */ +.card { + background: var(--surface-primary); + border: 0.5px solid var(--border-default); + border-radius: var(--radius-lg); + transition: all var(--duration-normal) var(--ease-default); +} + +.card:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-glow); +} + +/* ============================================= + 7. Alerts + ============================================= */ +.alert { + display: flex; + align-items: 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); + border: 0.5px solid transparent; +} + +.alert-danger { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.25); + color: var(--color-error-light); +} + +.alert-success { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.25); + color: var(--color-success-light); +} + +.alert-warning { + background: rgba(251, 191, 36, 0.12); + border-color: rgba(251, 191, 36, 0.25); + color: var(--color-warning-light); +} + +.alert-info { + background: rgba(96, 165, 250, 0.12); + border-color: rgba(96, 165, 250, 0.25); + color: var(--color-info-light); +} + +/* ============================================= + 8. Badges + ============================================= */ +.badge { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-2); + font-family: var(--font-family-base); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + border-radius: var(--radius-full); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bg-success { background-color: var(--color-success) !important; color: #000; } +.bg-warning { background-color: var(--color-warning) !important; color: #000; } +.bg-danger { background-color: var(--color-error) !important; } +.bg-secondary { background-color: var(--surface-tertiary) !important; } + +/* ============================================= + 9. Auth States + ============================================= */ +.auth-loading, +.auth-redirect { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + padding: var(--space-6); + text-align: center; + gap: var(--space-4); + font-family: var(--font-family-base); + color: var(--text-tertiary); +} + +.auth-loading .spinner-border { + width: 2.5rem; + height: 2.5rem; + color: var(--color-primary); +} + +.auth-redirect .auth-icon { + font-size: 3rem; +} + +.auth-redirect h2 { + font-family: var(--font-family-base); + font-size: var(--text-xl); + color: var(--text-primary); + margin: 0; +} + +.auth-redirect p { + color: var(--text-tertiary); + margin: 0 0 var(--space-4) 0; +} + +/* ============================================= + 10. Modals + ============================================= */ +.global-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + z-index: var(--z-modal-backdrop); + animation: fadeIn var(--duration-fast) var(--ease-out); +} + +.global-modal-container { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + padding: var(--space-4); + padding-bottom: calc(var(--space-4) + var(--safe-area-bottom)); +} + +.global-modal-dialog { + background: var(--surface-primary); + border: 0.5px solid var(--border-default); + border-radius: var(--radius-xl); + width: 100%; + max-width: 380px; + max-height: calc(100vh - var(--space-8)); + max-height: calc(100dvh - var(--space-8)); + overflow-y: auto; + box-shadow: var(--shadow-lg); + animation: modalSlideIn var(--duration-normal) var(--ease-spring); +} + +.global-modal-dialog-lg { + max-width: 480px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.92) translateY(-16px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.global-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + border-bottom: 0.5px solid var(--border-light); +} + +.global-modal-header h3 { + margin: 0; + font-family: var(--font-family-base); + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); +} + +.global-modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 50%; + color: var(--text-tertiary); + font-size: var(--text-base); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); +} + +.global-modal-close:hover { + background: rgba(255, 255, 255, 0.12); + color: var(--text-primary); +} + +.global-modal-body { + padding: var(--space-6); +} + +.global-modal-body p { + margin: 0; + font-family: var(--font-family-base); + color: var(--text-secondary); + text-align: center; + line-height: var(--line-height-relaxed); +} + +.global-modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + padding: var(--space-4) var(--space-6); + border-top: 0.5px solid var(--border-light); +} + +.global-modal-icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin: 0 auto var(--space-5); + border-radius: 50%; +} + +.global-modal-icon-warning { + background: rgba(239, 68, 68, 0.15); + color: var(--color-error); +} + +/* Modal Form Controls */ +.global-modal-form-group { + margin-bottom: var(--space-4); +} + +.global-modal-form-label { + display: block; + margin-bottom: var(--space-2); + font-family: var(--font-family-base); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.global-modal-form-control { + width: 100%; + padding: var(--space-3) var(--space-4); + background: rgba(0, 0, 0, 0.3); + border: 0.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); + transition: all var(--duration-fast) var(--ease-default); +} + +.global-modal-form-control:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); +} + +.global-modal-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: var(--touch-target); + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-md); + 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); + border: none; +} + +.global-modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.global-modal-btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); + border: 0.5px solid var(--border-default); +} + +.global-modal-btn-primary, +.global-modal-btn-danger { + background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-primary) 100%); + color: var(--text-primary); + box-shadow: var(--shadow-btn-primary); +} + +/* ============================================= + 10b. Universal Modal System + + Strategy: We use a CUSTOM class ".app-modal" instead of + Bootstrap's ".modal" to completely avoid Bootstrap's + .modal { display:none } and all other .modal rules. + + Dashboard keeps its own flat .modal pattern with scoped CSS + and is NOT affected by these rules. + + .modal-dialog and .modal-content use !important on critical + properties to guarantee they override Bootstrap regardless + of load order, specificity, or caching. + ============================================= */ + +/* Backdrop overlay */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + z-index: var(--z-modal-backdrop, 900); + animation: fadeIn var(--duration-fast, 150ms) var(--ease-out, cubic-bezier(0.25, 1, 0.5, 1)); +} + +/* + * Custom modal overlay — replaces Bootstrap's .modal class. + * By using .app-modal, Bootstrap's .modal{display:none} never applies. + * This is a transparent full-screen overlay that captures clicks + * for closing. The actual dialog is .modal-dialog inside it. + */ +.app-modal { + position: fixed; + inset: 0; + z-index: var(--z-modal, 1000); + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + outline: none; +} + +/* + * Dialog container — centered in viewport. + * !important on critical properties guarantees we beat + * Bootstrap's .modal-dialog { pointer-events:none; position:relative } + * regardless of specificity or cascade order. + * Dashboard does NOT use .modal-dialog, so !important is safe. + */ +.modal-dialog { + position: relative !important; + width: calc(100% - 2rem); + max-width: 520px; + z-index: var(--z-modal, 1000); + pointer-events: auto !important; + margin: 0 !important; +} + +.modal-dialog-sm { + max-width: 380px; +} + +.modal-dialog-lg { + max-width: 640px; +} + +/* + * Visual dialog box. + * !important on background to guarantee dark theme over + * Bootstrap's .modal-content { background-color: #fff }. + * Dashboard does NOT use .modal-content, so !important is safe. + */ +.modal-content { + background: var(--surface-primary, rgba(28, 28, 30, 0.95)) !important; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 0.5px solid var(--border-default, rgba(255, 255, 255, 0.1)) !important; + border-radius: var(--radius-xl, 20px) !important; + width: 100%; + max-height: calc(100vh - var(--space-8, 2rem)); + max-height: calc(100dvh - var(--space-8, 2rem)); + overflow-y: auto; + box-shadow: var(--shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.5)); + animation: modalSlideIn var(--duration-normal, 250ms) var(--ease-spring, cubic-bezier(0.175, 0.885, 0.32, 1.275)); + color: var(--text-primary, #ffffff) !important; + pointer-events: auto !important; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5, 1.25rem) var(--space-6, 1.5rem); + border-bottom: 0.5px solid var(--border-light, rgba(255, 255, 255, 0.06)); + gap: var(--space-4, 1rem); +} + +.modal-header h3, +.modal-header h5, +.modal-title { + margin: 0; + font-family: var(--font-family-base); + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); + color: var(--text-primary, #ffffff); +} + +.modal-body { + padding: var(--space-6, 1.5rem); +} + +.modal-body .form-label { + display: block; + font-family: var(--font-family-base); + font-weight: var(--font-weight-medium); + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + font-size: var(--text-sm); + margin-bottom: var(--space-2); +} + +.modal-body .required { + color: var(--color-error-light); +} + +.modal-body .mb-3 { + margin-bottom: var(--space-4); +} + +.modal-footer { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4, 1rem) var(--space-6, 1.5rem); + border-top: 0.5px solid var(--border-light, rgba(255, 255, 255, 0.06)); +} + +@media (min-width: 480px) { + .modal-footer { + flex-direction: row; + justify-content: flex-end; + } + + .modal-footer .btn { + width: auto; + } +} + +@media (max-width: 479.98px) { + .modal-footer .btn { + width: 100%; + justify-content: center; + } +} + +.btn-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + min-width: 32px; + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 50%; + color: var(--text-tertiary); + font-size: var(--text-base); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); +} + +.btn-close::before { + content: '\00D7'; +} + +.btn-close:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); +} + +.btn-close:active { + transform: scale(0.9); +} + +.btn-close:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================= + 11. Spinners + ============================================= */ +.spinner-border { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + color: var(--color-primary); +} + +.spinner-border-sm { + width: 1rem; + height: 1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================= + 12. Validation States + ============================================= */ +.valid.modified:not([type=checkbox]) { + outline: 1px solid var(--color-success); +} + +.invalid { + outline: 1px solid var(--color-error); +} + +.validation-message { + color: var(--color-error-light); + font-size: var(--text-sm); + margin-top: var(--space-1); +} + +/* ============================================= + 13. Error Boundary + ============================================= */ +.blazor-error-boundary { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + padding: var(--space-4); + border-radius: var(--radius-md); + color: var(--color-error-light); +} + +.blazor-error-boundary::after { + content: "Ein Fehler ist aufgetreten. Bitte laden Sie die Seite neu."; +} + +/* ============================================= + 14. Utility Classes + ============================================= */ +/* Text Colors */ +.text-primary { color: var(--text-primary) !important; } +.text-secondary { color: var(--text-secondary) !important; } +.text-muted { color: var(--text-tertiary) !important; } +.text-success { color: var(--color-success) !important; } +.text-danger { color: var(--color-error) !important; } +.text-warning { color: var(--color-warning) !important; } + +/* Text Alignment & Formatting */ +.text-center { text-align: center; } +.text-break { word-break: break-all; } +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Font Weights */ +.fw-bold { font-weight: var(--font-weight-bold); } +.fw-semibold { font-weight: var(--font-weight-semibold); } + +/* Margins */ +.mb-0 { margin-bottom: 0; } +.mb-2 { margin-bottom: var(--space-2); } +.mb-3 { margin-bottom: var(--space-4); } +.mb-4 { margin-bottom: var(--space-6); } +.mt-2 { margin-top: var(--space-2); } +.mt-3 { margin-top: var(--space-4); } +.mt-4 { margin-top: var(--space-6); } +.me-1 { margin-right: var(--space-1); } +.me-2 { margin-right: var(--space-2); } + +/* Display */ +.d-flex { display: flex; } +.d-block { display: block; } +.d-none { display: none; } + +/* Flexbox */ +.flex-column { flex-direction: column; } +.align-items-center { align-items: center; } +.justify-content-center { justify-content: center; } +.justify-content-between { justify-content: space-between; } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-4); } +.gap-4 { gap: var(--space-6); } + +/* Width */ +.w-100 { width: 100%; } + +/* Visually hidden but accessible */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Skip Link for Accessibility (keyboard navigation) */ +.skip-link { + position: absolute; + top: -100%; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + padding: var(--space-3) var(--space-6); + background: var(--color-primary); + color: var(--text-primary); + font-family: var(--font-family-base); + font-weight: var(--font-weight-semibold); + text-decoration: none; + border-radius: var(--radius-md); + transition: top var(--duration-fast) var(--ease-out); +} + +.skip-link:focus { + top: var(--space-4); + outline: 2px solid var(--text-primary); + outline-offset: 2px; +} + +/* Visually hidden but focusable (for skip links) */ +.visually-hidden-focusable:not(:focus):not(:focus-within) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ============================================= + 15. Animations + ============================================= */ +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn var(--duration-normal) var(--ease-out); +} + +.slide-up { + animation: slideUp var(--duration-normal) var(--ease-out); +} + +/* ============================================= + 16. Responsive Utilities + ============================================= */ + +/* Table scroll wrapper */ +.table-responsive { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; +} + +.table-responsive::-webkit-scrollbar { + height: 6px; +} + +.table-responsive::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.table-responsive::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +/* Mobile-specific adjustments */ +@media (max-width: 767.98px) { + body { + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); + } + + .btn, + button:not(.btn-icon-sm), + [role="button"], + select { + min-height: var(--touch-target); + } + + .form-control, + .form-select { + min-height: 48px; + padding: 12px 14px; + font-size: 16px; + } + + .hide-mobile { + display: none !important; + } + + .flex-mobile-column { + flex-direction: column !important; + } + + .w-mobile-100 { + width: 100% !important; + } +} + +@media (min-width: 768px) { + .hide-desktop { + display: none !important; + } +} + +/* Page container */ +.page-container { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: var(--space-page-y) var(--space-page-x); +} + +@media (max-width: 767.98px) { + .page-container { + padding: 1rem 0.75rem; + padding-bottom: calc(1rem + var(--safe-area-bottom)); + } +} + +/* Safe Areas for Notched Devices */ +@supports (padding: env(safe-area-inset-top)) { + .safe-area-top { padding-top: env(safe-area-inset-top); } + .safe-area-bottom { padding-bottom: env(safe-area-inset-bottom); } + .safe-area-x { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} + +/* ============================================= + 17. Reduced Motion + ============================================= */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ============================================= + 18. Print Styles + ============================================= */ +@media print { + body { + background: #fff; + color: #000; + } + + .btn, + .global-modal-backdrop, + .global-modal-container { + display: none !important; + } +} + +/* ============================================= + 19. Emoji & Symbol Styling + ============================================= */ +.emoji, +.icon-emoji, +.season-icon { + font-family: var(--font-family-emoji); + font-style: normal; + font-weight: normal; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.empty-icon, +.access-denied-icon, +.feature-icon { + font-family: var(--font-family-emoji); + font-size: var(--emoji-xl); + line-height: 1; + display: block; + text-align: center; +} + +.empty-icon, +.access-denied-icon { + font-size: 3rem; + margin-bottom: var(--space-4); +} + +/* Access denied styling */ +.access-denied { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + padding: var(--space-6); + text-align: center; + animation: fadeIn var(--duration-normal) var(--ease-out); +} + +.access-denied h2 { + font-family: var(--font-family-base); + font-size: var(--text-xl); + color: var(--text-primary); + margin: var(--space-4) 0 var(--space-2); +} + +.access-denied p { + color: var(--text-tertiary); + margin-bottom: var(--space-6); + font-family: var(--font-family-base); +} + +/* ============================================= + 20. Global Responsive Grid Patterns + ============================================= */ + +/* Auto-fit grid for cards/items - mobile first */ +.responsive-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +@media (min-width: 480px) { + .responsive-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 768px) { + .responsive-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1024px) { + .responsive-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Stats grid - for statistics cards */ +.stats-grid-responsive { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); +} + +@media (min-width: 768px) { + .stats-grid-responsive { + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + } +} + +/* Form row - side by side on desktop, stacked on mobile */ +.form-row-responsive { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +@media (min-width: 768px) { + .form-row-responsive { + flex-direction: row; + } + + .form-row-responsive > * { + flex: 1; + } +} + +/* Actions row - wrap on mobile */ +.actions-responsive { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +@media (max-width: 479.98px) { + .actions-responsive { + flex-direction: column; + } + + .actions-responsive > .btn { + width: 100%; + justify-content: center; + } +} + +/* Page header - title and actions */ +.page-header-responsive { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +@media (min-width: 768px) { + .page-header-responsive { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +/* Modal content - responsive padding */ +.modal-responsive { + width: calc(100% - 1rem); + max-width: 600px; + max-height: calc(100vh - 2rem); + margin: 0.5rem; + padding: var(--space-4); +} + +@media (min-width: 768px) { + .modal-responsive { + width: 90%; + padding: var(--space-6); + margin: 1rem auto; + } +} + +/* Two column layout - sidebar + main */ +.layout-two-column { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +@media (min-width: 1024px) { + .layout-two-column { + flex-direction: row; + } + + .layout-two-column > .main-content { + flex: 1; + min-width: 0; + } + + .layout-two-column > .sidebar-content { + width: 320px; + flex-shrink: 0; + } +} + +/* Kanban board - horizontal scroll on mobile */ +.kanban-responsive { + display: flex; + gap: var(--space-4); + overflow-x: auto; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + padding-bottom: var(--space-2); + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; +} + +.kanban-responsive::-webkit-scrollbar { + height: 6px; +} + +.kanban-responsive::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +.kanban-responsive > .column { + flex: 0 0 85vw; + min-width: 280px; + max-width: 320px; + scroll-snap-align: start; +} + +@media (min-width: 768px) { + .kanban-responsive > .column { + flex: 0 0 300px; + } +} + +@media (min-width: 1200px) { + .kanban-responsive { + overflow-x: visible; + } + + .kanban-responsive > .column { + flex: 1; + max-width: none; + } +} + +/* Card grid inside modals */ +.modal-card-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-3); +} + +@media (min-width: 600px) { + .modal-card-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Input group inline */ +.input-group-responsive { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +@media (min-width: 480px) { + .input-group-responsive { + flex-direction: row; + } + + .input-group-responsive > input, + .input-group-responsive > select { + flex: 1; + } + + .input-group-responsive > .btn { + flex-shrink: 0; + } +} diff --git a/Lagerbestand/wwwroot/bootstrap/bootstrap.min.css b/Lagerbestand/wwwroot/bootstrap/bootstrap.min.css new file mode 100644 index 0000000..02ae65b --- /dev/null +++ b/Lagerbestand/wwwroot/bootstrap/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.0 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:last-child)>:last-child>*{border-bottom-color:currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-striped>tbody>tr:nth-of-type(odd){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}.ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}.ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/Lagerbestand/wwwroot/bootstrap/bootstrap.min.css.map b/Lagerbestand/wwwroot/bootstrap/bootstrap.min.css.map new file mode 100644 index 0000000..afcd9e3 --- /dev/null +++ b/Lagerbestand/wwwroot/bootstrap/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBAAA;;;;;ACAA,MAQI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EACA,cAAA,EAAA,CAAA,EAAA,CAAA,GAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAQA,sBAAA,0BACA,oBAAA,KACA,sBAAA,IACA,sBAAA,IACA,gBAAA,QAIA,aAAA,KClCF,EC+CA,QADA,SD3CE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BEmPI,UAAA,yBFjPJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YAUF,GACE,OAAA,KAAA,EACA,MAAA,QACA,iBAAA,aACA,OAAA,EACA,QAAA,IAGF,eACE,OAAA,IAUF,IAAA,IAAA,IAAA,IAAA,IAAA,IAAA,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IAIF,IAAA,GEwMQ,UAAA,uBAlKJ,0BFtCJ,IAAA,GE+MQ,UAAA,QF1MR,IAAA,GEmMQ,UAAA,sBAlKJ,0BFjCJ,IAAA,GE0MQ,UAAA,MFrMR,IAAA,GE8LQ,UAAA,oBAlKJ,0BF5BJ,IAAA,GEqMQ,UAAA,SFhMR,IAAA,GEyLQ,UAAA,sBAlKJ,0BFvBJ,IAAA,GEgMQ,UAAA,QF3LR,IAAA,GEgLM,UAAA,QF3KN,IAAA,GE2KM,UAAA,KFhKN,EACE,WAAA,EACA,cAAA,KCmBF,6BDRA,YAEE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GCIA,GDFE,aAAA,KCQF,GDLA,GCIA,GDDE,WAAA,EACA,cAAA,KAGF,MCKA,MACA,MAFA,MDAE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,ECNA,ODQE,YAAA,OAQF,OAAA,ME4EM,UAAA,OFrEN,MAAA,KACE,QAAA,KACA,iBAAA,QASF,ICpBA,IDsBE,SAAA,SEwDI,UAAA,MFtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,QACA,gBAAA,UAEA,QACE,MAAA,QAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KCxBJ,KACA,ID8BA,IC7BA,KDiCE,YAAA,yBEcI,UAAA,IFZJ,UAAA,IACA,aAAA,cAOF,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KEAI,UAAA,OFKJ,SELI,UAAA,QFOF,MAAA,QACA,WAAA,OAIJ,KEZM,UAAA,OFcJ,MAAA,QACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,MAAA,MExBI,UAAA,OF0BJ,MAAA,KACA,iBAAA,QG7SE,cAAA,MHgTF,QACE,QAAA,EE/BE,UAAA,IFiCF,YAAA,IASJ,OACE,OAAA,EAAA,EAAA,KAMF,ICjDA,IDmDE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,QACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBCxDF,MAGA,GAFA,MAGA,GDuDA,MCzDA,GD+DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,ECtEF,OD2EA,MCzEA,SADA,OAEA,SD6EE,OAAA,EACA,YAAA,QE9HI,UAAA,QFgIJ,YAAA,QAIF,OC5EA,OD8EE,eAAA,KAKF,cACE,OAAA,QAGF,OAGE,UAAA,OAGA,gBACE,QAAA,EAOJ,0CACE,QAAA,KClFF,cACA,aACA,cDwFA,OAIE,mBAAA,OCxFF,6BACA,4BACA,6BDyFI,sBACE,OAAA,QAON,mBACE,QAAA,EACA,aAAA,KAKF,SACE,OAAA,SAUF,SACE,UAAA,EACA,QAAA,EACA,OAAA,EACA,OAAA,EAQF,OACE,MAAA,KACA,MAAA,KACA,QAAA,EACA,cAAA,MEnNM,UAAA,sBFsNN,YAAA,QExXE,0BFiXJ,OExMQ,UAAA,QFiNN,SACE,MAAA,KChGJ,kCDuGA,uCCxGA,mCADA,+BAGA,oCAJA,6BAKA,mCD4GE,QAAA,EAGF,4BACE,OAAA,KASF,cACE,eAAA,KACA,mBAAA,UAmBF,4BACE,mBAAA,KAKF,+BACE,QAAA,EAMF,uBACE,KAAA,QAMF,6BACE,KAAA,QACA,mBAAA,OAKF,OACE,QAAA,aAKF,OACE,OAAA,EAOF,QACE,QAAA,UACA,OAAA,QAQF,SACE,eAAA,SAQF,SACE,QAAA,eInlBF,MFyQM,UAAA,QEvQJ,YAAA,IAKA,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QE7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QE7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QEvPR,eCrDE,aAAA,EACA,WAAA,KDyDF,aC1DE,aAAA,EACA,WAAA,KD4DF,kBACE,QAAA,aAEA,mCACE,aAAA,MAUJ,YFsNM,UAAA,OEpNJ,eAAA,UAIF,YACE,cAAA,KF+MI,UAAA,QE5MJ,wBACE,cAAA,EAIJ,mBACE,WAAA,MACA,cAAA,KFqMI,UAAA,OEnMJ,MAAA,QAEA,2BACE,QAAA,KE9FJ,WCIE,UAAA,KAGA,OAAA,KDDF,eACE,QAAA,OACA,iBAAA,KACA,OAAA,IAAA,MAAA,QHGE,cAAA,OIRF,UAAA,KAGA,OAAA,KDcF,QAEE,QAAA,aAGF,YACE,cAAA,MACA,YAAA,EAGF,gBJ+PM,UAAA,OI7PJ,MAAA,QElCA,WPqmBF,iBAGA,cACA,cACA,cAHA,cADA,eQzmBE,MAAA,KACA,cAAA,0BACA,aAAA,0BACA,aAAA,KACA,YAAA,KCwDE,yBF5CE,WAAA,cACE,UAAA,OE2CJ,yBF5CE,WAAA,cAAA,cACE,UAAA,OE2CJ,yBF5CE,WAAA,cAAA,cAAA,cACE,UAAA,OE2CJ,0BF5CE,WAAA,cAAA,cAAA,cAAA,cACE,UAAA,QE2CJ,0BF5CE,WAAA,cAAA,cAAA,cAAA,cAAA,eACE,UAAA,QGfN,KCAA,cAAA,OACA,cAAA,EACA,QAAA,KACA,UAAA,KACA,WAAA,8BACA,aAAA,+BACA,YAAA,+BDHE,OCYF,YAAA,EACA,MAAA,KACA,UAAA,KACA,cAAA,8BACA,aAAA,8BACA,WAAA,mBA+CI,KACE,KAAA,EAAA,EAAA,GAGF,iBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,cACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,UAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,UAxDV,YAAA,YAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,WAxDV,YAAA,aAwDU,WAxDV,YAAA,aAmEM,KXusBR,MWrsBU,cAAA,EAGF,KXusBR,MWrsBU,cAAA,EAPF,KXitBR,MW/sBU,cAAA,QAGF,KXitBR,MW/sBU,cAAA,QAPF,KX2tBR,MWztBU,cAAA,OAGF,KX2tBR,MWztBU,cAAA,OAPF,KXquBR,MWnuBU,cAAA,KAGF,KXquBR,MWnuBU,cAAA,KAPF,KX+uBR,MW7uBU,cAAA,OAGF,KX+uBR,MW7uBU,cAAA,OAPF,KXyvBR,MWvvBU,cAAA,KAGF,KXyvBR,MWvvBU,cAAA,KFzDN,yBESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QX45BR,SW15BU,cAAA,EAGF,QX45BR,SW15BU,cAAA,EAPF,QXs6BR,SWp6BU,cAAA,QAGF,QXs6BR,SWp6BU,cAAA,QAPF,QXg7BR,SW96BU,cAAA,OAGF,QXg7BR,SW96BU,cAAA,OAPF,QX07BR,SWx7BU,cAAA,KAGF,QX07BR,SWx7BU,cAAA,KAPF,QXo8BR,SWl8BU,cAAA,OAGF,QXo8BR,SWl8BU,cAAA,OAPF,QX88BR,SW58BU,cAAA,KAGF,QX88BR,SW58BU,cAAA,MFzDN,yBESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QXinCR,SW/mCU,cAAA,EAGF,QXinCR,SW/mCU,cAAA,EAPF,QX2nCR,SWznCU,cAAA,QAGF,QX2nCR,SWznCU,cAAA,QAPF,QXqoCR,SWnoCU,cAAA,OAGF,QXqoCR,SWnoCU,cAAA,OAPF,QX+oCR,SW7oCU,cAAA,KAGF,QX+oCR,SW7oCU,cAAA,KAPF,QXypCR,SWvpCU,cAAA,OAGF,QXypCR,SWvpCU,cAAA,OAPF,QXmqCR,SWjqCU,cAAA,KAGF,QXmqCR,SWjqCU,cAAA,MFzDN,yBESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QXs0CR,SWp0CU,cAAA,EAGF,QXs0CR,SWp0CU,cAAA,EAPF,QXg1CR,SW90CU,cAAA,QAGF,QXg1CR,SW90CU,cAAA,QAPF,QX01CR,SWx1CU,cAAA,OAGF,QX01CR,SWx1CU,cAAA,OAPF,QXo2CR,SWl2CU,cAAA,KAGF,QXo2CR,SWl2CU,cAAA,KAPF,QX82CR,SW52CU,cAAA,OAGF,QX82CR,SW52CU,cAAA,OAPF,QXw3CR,SWt3CU,cAAA,KAGF,QXw3CR,SWt3CU,cAAA,MFzDN,0BESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QX2hDR,SWzhDU,cAAA,EAGF,QX2hDR,SWzhDU,cAAA,EAPF,QXqiDR,SWniDU,cAAA,QAGF,QXqiDR,SWniDU,cAAA,QAPF,QX+iDR,SW7iDU,cAAA,OAGF,QX+iDR,SW7iDU,cAAA,OAPF,QXyjDR,SWvjDU,cAAA,KAGF,QXyjDR,SWvjDU,cAAA,KAPF,QXmkDR,SWjkDU,cAAA,OAGF,QXmkDR,SWjkDU,cAAA,OAPF,QX6kDR,SW3kDU,cAAA,KAGF,QX6kDR,SW3kDU,cAAA,MFzDN,0BESE,SACE,KAAA,EAAA,EAAA,GAGF,qBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,cAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,cAxDV,YAAA,EAwDU,cAxDV,YAAA,YAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,eAxDV,YAAA,aAwDU,eAxDV,YAAA,aAmEM,SXgvDR,UW9uDU,cAAA,EAGF,SXgvDR,UW9uDU,cAAA,EAPF,SX0vDR,UWxvDU,cAAA,QAGF,SX0vDR,UWxvDU,cAAA,QAPF,SXowDR,UWlwDU,cAAA,OAGF,SXowDR,UWlwDU,cAAA,OAPF,SX8wDR,UW5wDU,cAAA,KAGF,SX8wDR,UW5wDU,cAAA,KAPF,SXwxDR,UWtxDU,cAAA,OAGF,SXwxDR,UWtxDU,cAAA,OAPF,SXkyDR,UWhyDU,cAAA,KAGF,SXkyDR,UWhyDU,cAAA,MCpHV,OACE,cAAA,YACA,qBAAA,YACA,yBAAA,QACA,sBAAA,oBACA,wBAAA,QACA,qBAAA,mBACA,uBAAA,QACA,oBAAA,qBAEA,MAAA,KACA,cAAA,KACA,MAAA,QACA,eAAA,IACA,aAAA,QAOA,yBACE,QAAA,MAAA,MACA,iBAAA,mBACA,oBAAA,IACA,WAAA,MAAA,EAAA,EAAA,EAAA,OAAA,0BAGF,aACE,eAAA,QAGF,aACE,eAAA,OAIF,uCACE,oBAAA,aASJ,aACE,aAAA,IAUA,4BACE,QAAA,OAAA,OAeF,gCACE,aAAA,IAAA,EAGA,kCACE,aAAA,EAAA,IAOJ,oCACE,oBAAA,EASF,yCACE,qBAAA,2BACA,MAAA,8BAQJ,cACE,qBAAA,0BACA,MAAA,6BAQA,4BACE,qBAAA,yBACA,MAAA,4BCxHF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,iBAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,YAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,cAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,aAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,YAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QDgIA,kBACE,WAAA,KACA,2BAAA,MHvEF,4BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,4BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,4BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,6BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,6BGqEA,sBACE,WAAA,KACA,2BAAA,OE/IN,YACE,cAAA,MASF,gBACE,YAAA,oBACA,eAAA,oBACA,cAAA,EboRI,UAAA,QahRJ,YAAA,IAIF,mBACE,YAAA,kBACA,eAAA,kBb0QI,UAAA,QatQN,mBACE,YAAA,mBACA,eAAA,mBboQI,UAAA,QcjSN,WACE,WAAA,OdgSI,UAAA,Oc5RJ,MAAA,QCLF,cACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,Of8RI,UAAA,Ke3RJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,QACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KdGE,cAAA,OeHE,WAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCDhBN,cCiBQ,WAAA,MDGN,yBACE,SAAA,OAEA,wDACE,OAAA,QAKJ,oBACE,MAAA,QACA,iBAAA,KACA,aAAA,QACA,QAAA,EAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAOJ,2CAEE,OAAA,MAIF,gCACE,MAAA,QAEA,QAAA,EAHF,2BACE,MAAA,QAEA,QAAA,EAQF,uBAAA,wBAEE,iBAAA,QAGA,QAAA,EAIF,oCACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE3EF,iBAAA,QF6EE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECtEE,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCDuDJ,oCCtDM,WAAA,MDqEN,yEACE,iBAAA,QAGF,0CACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE9FF,iBAAA,QFgGE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECzFE,mBAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCD0EJ,0CCzEM,mBAAA,KAAA,WAAA,MDwFN,+EACE,iBAAA,QASJ,wBACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,EACA,cAAA,EACA,YAAA,IACA,MAAA,QACA,iBAAA,YACA,OAAA,MAAA,YACA,aAAA,IAAA,EAEA,wCAAA,wCAEE,cAAA,EACA,aAAA,EAWJ,iBACE,WAAA,0BACA,QAAA,OAAA,MfmJI,UAAA,QClRF,cAAA,McmIF,uCACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAGF,6CACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAIJ,iBACE,WAAA,yBACA,QAAA,MAAA,KfgII,UAAA,QClRF,cAAA,McsJF,uCACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAGF,6CACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAQF,sBACE,WAAA,2BAGF,yBACE,WAAA,0BAGF,yBACE,WAAA,yBAKJ,oBACE,MAAA,KACA,OAAA,KACA,QAAA,QAEA,mDACE,OAAA,QAGF,uCACE,OAAA,Md/LA,cAAA,OcmMF,0CACE,OAAA,MdpMA,cAAA,OiBdJ,aACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,QAAA,QAAA,OAEA,mBAAA,oBlB2RI,UAAA,KkBxRJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,iBAAA,KACA,iBAAA,gOACA,kBAAA,UACA,oBAAA,MAAA,OAAA,OACA,gBAAA,KAAA,KACA,OAAA,IAAA,MAAA,QjBFE,cAAA,OeHE,WAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YESJ,mBAAA,KAAA,gBAAA,KAAA,WAAA,KFLI,uCEfN,aFgBQ,WAAA,MEMN,mBACE,aAAA,QACA,QAAA,EAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,uBAAA,mCAEE,cAAA,OACA,iBAAA,KAGF,sBAEE,iBAAA,QAKF,4BACE,MAAA,YACA,YAAA,EAAA,EAAA,EAAA,QAIJ,gBACE,YAAA,OACA,eAAA,OACA,aAAA,MlByOI,UAAA,QkBrON,gBACE,YAAA,MACA,eAAA,MACA,aAAA,KlBkOI,UAAA,QmBjSN,YACE,QAAA,MACA,WAAA,OACA,aAAA,MACA,cAAA,QAEA,8BACE,MAAA,KACA,YAAA,OAIJ,kBACE,MAAA,IACA,OAAA,IACA,WAAA,MACA,eAAA,IACA,iBAAA,KACA,kBAAA,UACA,oBAAA,OACA,gBAAA,QACA,OAAA,IAAA,MAAA,gBACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KACA,2BAAA,MAAA,aAAA,MAGA,iClBXE,cAAA,MkBeF,8BAEE,cAAA,IAGF,yBACE,OAAA,gBAGF,wBACE,aAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,0BACE,iBAAA,QACA,aAAA,QAEA,yCAII,iBAAA,8NAIJ,sCAII,iBAAA,sIAKN,+CACE,iBAAA,QACA,aAAA,QAKE,iBAAA,wNAIJ,2BACE,eAAA,KACA,OAAA,KACA,QAAA,GAOA,6CAAA,8CACE,QAAA,GAcN,aACE,aAAA,MAEA,+BACE,MAAA,IACA,YAAA,OACA,iBAAA,uJACA,oBAAA,KAAA,OlB9FA,cAAA,IeHE,WAAA,oBAAA,KAAA,YAIA,uCGyFJ,+BHxFM,WAAA,MGgGJ,qCACE,iBAAA,yIAGF,uCACE,oBAAA,MAAA,OAKE,iBAAA,sIAMR,mBACE,QAAA,aACA,aAAA,KAGF,WACE,SAAA,SACA,KAAA,cACA,eAAA,KAIE,yBAAA,0BACE,eAAA,KACA,OAAA,KACA,QAAA,IC9IN,YACE,MAAA,KACA,OAAA,OACA,QAAA,EACA,iBAAA,YACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KAEA,kBACE,QAAA,EAIA,wCAA0B,WAAA,EAAA,EAAA,EAAA,IAAA,IAAA,CAAA,EAAA,EAAA,EAAA,OAAA,qBAC1B,oCAA0B,WAAA,EAAA,EAAA,EAAA,IAAA,IAAA,CAAA,EAAA,EAAA,EAAA,OAAA,qBAG5B,8BACE,OAAA,EAGF,kCACE,MAAA,KACA,OAAA,KACA,WAAA,QHzBF,iBAAA,QG2BE,OAAA,EnBZA,cAAA,KeHE,mBAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YImBF,mBAAA,KAAA,WAAA,KJfE,uCIMJ,kCJLM,mBAAA,KAAA,WAAA,MIgBJ,yCHjCF,iBAAA,QGsCA,2CACE,MAAA,KACA,OAAA,MACA,MAAA,YACA,OAAA,QACA,iBAAA,QACA,aAAA,YnB7BA,cAAA,KmBkCF,8BACE,MAAA,KACA,OAAA,KHnDF,iBAAA,QGqDE,OAAA,EnBtCA,cAAA,KeHE,gBAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YI6CF,gBAAA,KAAA,WAAA,KJzCE,uCIiCJ,8BJhCM,gBAAA,KAAA,WAAA,MI0CJ,qCH3DF,iBAAA,QGgEA,8BACE,MAAA,KACA,OAAA,MACA,MAAA,YACA,OAAA,QACA,iBAAA,QACA,aAAA,YnBvDA,cAAA,KmB4DF,qBACE,eAAA,KAEA,2CACE,iBAAA,QAGF,uCACE,iBAAA,QCvFN,eACE,SAAA,SAEA,6BtB+iFF,4BsB7iFI,OAAA,mBACA,YAAA,KAGF,qBACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,OAAA,KACA,QAAA,KAAA,OACA,eAAA,KACA,OAAA,IAAA,MAAA,YACA,iBAAA,EAAA,ELDE,WAAA,QAAA,IAAA,WAAA,CAAA,UAAA,IAAA,YAIA,uCKXJ,qBLYM,WAAA,MKCN,6BACE,QAAA,KAAA,OAEA,+CACE,MAAA,YADF,0CACE,MAAA,YAGF,0DAEE,YAAA,SACA,eAAA,QAHF,mCAAA,qDAEE,YAAA,SACA,eAAA,QAGF,8CACE,YAAA,SACA,eAAA,QAIJ,4BACE,YAAA,SACA,eAAA,QAMA,gEACE,QAAA,IACA,UAAA,WAAA,mBAAA,mBAFF,yCtBmjFJ,2DACA,kCsBnjFM,QAAA,IACA,UAAA,WAAA,mBAAA,mBAKF,oDACE,QAAA,IACA,UAAA,WAAA,mBAAA,mBCtDN,aACE,SAAA,SACA,QAAA,KACA,UAAA,KACA,YAAA,QACA,MAAA,KAEA,2BvB2mFF,0BuBzmFI,SAAA,SACA,KAAA,EAAA,EAAA,KACA,MAAA,GACA,UAAA,EAIF,iCvBymFF,gCuBvmFI,QAAA,EAMF,kBACE,SAAA,SACA,QAAA,EAEA,wBACE,QAAA,EAWN,kBACE,QAAA,KACA,YAAA,OACA,QAAA,QAAA,OtBsPI,UAAA,KsBpPJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,OACA,YAAA,OACA,iBAAA,QACA,OAAA,IAAA,MAAA,QrBpCE,cAAA,OFuoFJ,qBuBzlFA,8BvBulFA,6BACA,kCuBplFE,QAAA,MAAA,KtBgOI,UAAA,QClRF,cAAA,MFgpFJ,qBuBzlFA,8BvBulFA,6BACA,kCuBplFE,QAAA,OAAA,MtBuNI,UAAA,QClRF,cAAA,MqBgEJ,6BvBulFA,6BuBrlFE,cAAA,KvB0lFF,uEuB7kFI,8FrB/DA,wBAAA,EACA,2BAAA,EFgpFJ,iEuB3kFI,2FrBtEA,wBAAA,EACA,2BAAA,EqBgFF,0IACE,YAAA,KrBpEA,uBAAA,EACA,0BAAA,EsBzBF,gBACE,QAAA,KACA,MAAA,KACA,WAAA,OvByQE,UAAA,OuBtQF,MAAA,QAGF,eACE,SAAA,SACA,IAAA,KACA,QAAA,EACA,QAAA,KACA,UAAA,KACA,QAAA,OAAA,MACA,WAAA,MvB4PE,UAAA,QuBzPF,MAAA,KACA,iBAAA,mBtB1BA,cAAA,OFmsFJ,0BACA,yBwBrqFI,sCxBmqFJ,qCwBjqFM,QAAA,MA9CF,uBAAA,mCAoDE,aAAA,QAGE,cAAA,qBACA,iBAAA,2OACA,kBAAA,UACA,oBAAA,MAAA,wBAAA,OACA,gBAAA,sBAAA,sBAGF,6BAAA,yCACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBAhEJ,2CAAA,+BAyEI,cAAA,qBACA,oBAAA,IAAA,wBAAA,MAAA,wBA1EJ,sBAAA,kCAiFE,aAAA,QAGE,kDAAA,gDAAA,8DAAA,4DAEE,cAAA,SACA,iBAAA,+NAAA,CAAA,2OACA,oBAAA,MAAA,OAAA,MAAA,CAAA,OAAA,MAAA,QACA,gBAAA,KAAA,IAAA,CAAA,sBAAA,sBAIJ,4BAAA,wCACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBA/FJ,2BAAA,uCAsGE,aAAA,QAEA,mCAAA,+CACE,iBAAA,QAGF,iCAAA,6CACE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,6CAAA,yDACE,MAAA,QAKJ,qDACE,YAAA,KAvHF,oCxBwwFJ,mCwBxwFI,gDxBuwFJ,+CwBxoFQ,QAAA,EAIF,0CxB0oFN,yCwB1oFM,sDxByoFN,qDwBxoFQ,QAAA,EAjHN,kBACE,QAAA,KACA,MAAA,KACA,WAAA,OvByQE,UAAA,OuBtQF,MAAA,QAGF,iBACE,SAAA,SACA,IAAA,KACA,QAAA,EACA,QAAA,KACA,UAAA,KACA,QAAA,OAAA,MACA,WAAA,MvB4PE,UAAA,QuBzPF,MAAA,KACA,iBAAA,mBtB1BA,cAAA,OF4xFJ,8BACA,6BwB9vFI,0CxB4vFJ,yCwB1vFM,QAAA,MA9CF,yBAAA,qCAoDE,aAAA,QAGE,cAAA,qBACA,iBAAA,2TACA,kBAAA,UACA,oBAAA,MAAA,wBAAA,OACA,gBAAA,sBAAA,sBAGF,+BAAA,2CACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBAhEJ,6CAAA,iCAyEI,cAAA,qBACA,oBAAA,IAAA,wBAAA,MAAA,wBA1EJ,wBAAA,oCAiFE,aAAA,QAGE,oDAAA,kDAAA,gEAAA,8DAEE,cAAA,SACA,iBAAA,+NAAA,CAAA,2TACA,oBAAA,MAAA,OAAA,MAAA,CAAA,OAAA,MAAA,QACA,gBAAA,KAAA,IAAA,CAAA,sBAAA,sBAIJ,8BAAA,0CACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBA/FJ,6BAAA,yCAsGE,aAAA,QAEA,qCAAA,iDACE,iBAAA,QAGF,mCAAA,+CACE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,+CAAA,2DACE,MAAA,QAKJ,uDACE,YAAA,KAvHF,sCxBi2FJ,qCwBj2FI,kDxBg2FJ,iDwB/tFQ,QAAA,EAEF,4CxBmuFN,2CwBnuFM,wDxBkuFN,uDwBjuFQ,QAAA,ECtIR,KACE,QAAA,aAEA,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,OACA,gBAAA,KAEA,eAAA,OACA,OAAA,QACA,oBAAA,KAAA,iBAAA,KAAA,YAAA,KACA,iBAAA,YACA,OAAA,IAAA,MAAA,YC8GA,QAAA,QAAA,OzBsKI,UAAA,KClRF,cAAA,OeHE,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCQhBN,KRiBQ,WAAA,MQAN,WACE,MAAA,QAIF,sBAAA,WAEE,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAcF,cAAA,cAAA,uBAGE,eAAA,KACA,QAAA,IAYF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,eCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,qBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,gCAAA,qBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,iCAAA,kCAAA,sBAAA,sBAAA,qCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,uCAAA,wCAAA,4BAAA,4BAAA,2CAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,wBAAA,wBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,UCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,gBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,2BAAA,gBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,4BAAA,6BAAA,iBAAA,iBAAA,gCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,kCAAA,mCAAA,uBAAA,uBAAA,sCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,mBAAA,mBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,YCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,kBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,6BAAA,kBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAIJ,8BAAA,+BAAA,mBAAA,mBAAA,kCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,oCAAA,qCAAA,yBAAA,yBAAA,wCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,qBAAA,qBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,WCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,iBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,4BAAA,iBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,6BAAA,8BAAA,kBAAA,kBAAA,iCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,mCAAA,oCAAA,wBAAA,wBAAA,uCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,oBAAA,oBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,UCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,gBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,2BAAA,gBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,kBAIJ,4BAAA,6BAAA,iBAAA,iBAAA,gCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,kCAAA,mCAAA,uBAAA,uBAAA,sCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,kBAKN,mBAAA,mBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDNF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,uBCmBA,MAAA,QACA,aAAA,QAEA,6BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,wCAAA,6BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,yCAAA,0CAAA,8BAAA,4CAAA,8BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,+CAAA,gDAAA,oCAAA,kDAAA,oCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,gCAAA,gCAEE,MAAA,QACA,iBAAA,YDvDF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,kBCmBA,MAAA,QACA,aAAA,QAEA,wBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,mCAAA,wBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,oCAAA,qCAAA,yBAAA,uCAAA,yBAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,0CAAA,2CAAA,+BAAA,6CAAA,+BAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,2BAAA,2BAEE,MAAA,QACA,iBAAA,YDvDF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,oBCmBA,MAAA,QACA,aAAA,QAEA,0BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,qCAAA,0BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,sCAAA,uCAAA,2BAAA,yCAAA,2BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,4CAAA,6CAAA,iCAAA,+CAAA,iCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,6BAAA,6BAEE,MAAA,QACA,iBAAA,YDvDF,mBCmBA,MAAA,QACA,aAAA,QAEA,yBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,oCAAA,yBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,qCAAA,sCAAA,0BAAA,wCAAA,0BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,2CAAA,4CAAA,gCAAA,8CAAA,gCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,4BAAA,4BAEE,MAAA,QACA,iBAAA,YDvDF,kBCmBA,MAAA,QACA,aAAA,QAEA,wBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,mCAAA,wBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,kBAGF,oCAAA,qCAAA,yBAAA,uCAAA,yBAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,0CAAA,2CAAA,+BAAA,6CAAA,+BAKI,WAAA,EAAA,EAAA,EAAA,OAAA,kBAKN,2BAAA,2BAEE,MAAA,QACA,iBAAA,YD3CJ,UACE,YAAA,IACA,MAAA,QACA,gBAAA,UAEA,gBACE,MAAA,QAQF,mBAAA,mBAEE,MAAA,QAWJ,mBAAA,QCuBE,QAAA,MAAA,KzBsKI,UAAA,QClRF,cAAA,MuByFJ,mBAAA,QCmBE,QAAA,OAAA,MzBsKI,UAAA,QClRF,cAAA,MyBnBJ,MVgBM,WAAA,QAAA,KAAA,OAIA,uCUpBN,MVqBQ,WAAA,MUlBN,iBACE,QAAA,EAMF,qBACE,QAAA,KAIJ,YACE,OAAA,EACA,SAAA,OVDI,WAAA,OAAA,KAAA,KAIA,uCULN,YVMQ,WAAA,MUDN,gCACE,MAAA,EACA,OAAA,KVNE,WAAA,MAAA,KAAA,KAIA,uCUAJ,gCVCM,WAAA,MjBs3GR,UADA,SAEA,W4B34GA,QAIE,SAAA,SAGF,iBACE,YAAA,OCqBE,wBACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAhCJ,WAAA,KAAA,MACA,aAAA,KAAA,MAAA,YACA,cAAA,EACA,YAAA,KAAA,MAAA,YAqDE,8BACE,YAAA,ED3CN,eACE,SAAA,SACA,QAAA,KACA,QAAA,KACA,UAAA,MACA,QAAA,MAAA,EACA,OAAA,E3B+QI,UAAA,K2B7QJ,MAAA,QACA,WAAA,KACA,WAAA,KACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,gB1BVE,cAAA,O0BcF,+BACE,IAAA,KACA,KAAA,EACA,WAAA,QAYA,qBACE,cAAA,MAEA,qCACE,MAAA,KACA,KAAA,EAIJ,mBACE,cAAA,IAEA,mCACE,MAAA,EACA,KAAA,KnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,0BmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,0BmBfA,yBACE,cAAA,MAEA,yCACE,MAAA,KACA,KAAA,EAIJ,uBACE,cAAA,IAEA,uCACE,MAAA,EACA,KAAA,MAUN,uCACE,IAAA,KACA,OAAA,KACA,WAAA,EACA,cAAA,QC9CA,gCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAzBJ,WAAA,EACA,aAAA,KAAA,MAAA,YACA,cAAA,KAAA,MACA,YAAA,KAAA,MAAA,YA8CE,sCACE,YAAA,ED0BJ,wCACE,IAAA,EACA,MAAA,KACA,KAAA,KACA,WAAA,EACA,YAAA,QC5DA,iCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAlBJ,WAAA,KAAA,MAAA,YACA,aAAA,EACA,cAAA,KAAA,MAAA,YACA,YAAA,KAAA,MAuCE,uCACE,YAAA,EDoCF,iCACE,eAAA,EAMJ,0CACE,IAAA,EACA,MAAA,KACA,KAAA,KACA,WAAA,EACA,aAAA,QC7EA,mCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAWA,mCACE,QAAA,KAGF,oCACE,QAAA,aACA,aAAA,OACA,eAAA,OACA,QAAA,GA9BN,WAAA,KAAA,MAAA,YACA,aAAA,KAAA,MACA,cAAA,KAAA,MAAA,YAiCE,yCACE,YAAA,EDqDF,oCACE,eAAA,EAON,kBACE,OAAA,EACA,OAAA,MAAA,EACA,SAAA,OACA,WAAA,IAAA,MAAA,gBAMF,eACE,QAAA,MACA,MAAA,KACA,QAAA,OAAA,KACA,MAAA,KACA,YAAA,IACA,MAAA,QACA,WAAA,QACA,gBAAA,KACA,YAAA,OACA,iBAAA,YACA,OAAA,EAcA,qBAAA,qBAEE,MAAA,QVzJF,iBAAA,QU8JA,sBAAA,sBAEE,MAAA,KACA,gBAAA,KVjKF,iBAAA,QUqKA,wBAAA,wBAEE,MAAA,QACA,eAAA,KACA,iBAAA,YAMJ,oBACE,QAAA,MAIF,iBACE,QAAA,MACA,QAAA,MAAA,KACA,cAAA,E3B0GI,UAAA,Q2BxGJ,MAAA,QACA,YAAA,OAIF,oBACE,QAAA,MACA,QAAA,OAAA,KACA,MAAA,QAIF,oBACE,MAAA,QACA,iBAAA,QACA,aAAA,gBAGA,mCACE,MAAA,QAEA,yCAAA,yCAEE,MAAA,KVhNJ,iBAAA,sBUoNE,0CAAA,0CAEE,MAAA,KVtNJ,iBAAA,QU0NE,4CAAA,4CAEE,MAAA,QAIJ,sCACE,aAAA,gBAGF,wCACE,MAAA,QAGF,qCACE,MAAA,QE5OJ,W9B2rHA,oB8BzrHE,SAAA,SACA,QAAA,YACA,eAAA,O9B6rHF,yB8B3rHE,gBACE,SAAA,SACA,KAAA,EAAA,EAAA,K9BmsHJ,4CACA,0CAIA,gCADA,gCADA,+BADA,+B8BhsHE,mC9ByrHF,iCAIA,uBADA,uBADA,sBADA,sB8BprHI,QAAA,EAKJ,aACE,QAAA,KACA,UAAA,KACA,gBAAA,WAEA,0BACE,MAAA,K9BgsHJ,wC8B1rHE,kCAEE,YAAA,K9B4rHJ,4C8BxrHE,uD5BRE,wBAAA,EACA,2BAAA,EFqsHJ,6C8BrrHE,+B9BorHF,iCEvrHI,uBAAA,EACA,0BAAA,E4BqBJ,uBACE,cAAA,SACA,aAAA,SAEA,8BAAA,uCAAA,sCAGE,YAAA,EAGF,0CACE,aAAA,EAIJ,0CAAA,+BACE,cAAA,QACA,aAAA,QAGF,0CAAA,+BACE,cAAA,OACA,aAAA,OAoBF,oBACE,eAAA,OACA,YAAA,WACA,gBAAA,OAEA,yB9BmpHF,+B8BjpHI,MAAA,K9BqpHJ,iD8BlpHE,2CAEE,WAAA,K9BopHJ,qD8BhpHE,gE5BvFE,2BAAA,EACA,0BAAA,EF2uHJ,sD8BhpHE,8B5B1GE,uBAAA,EACA,wBAAA,E6BxBJ,KACE,QAAA,KACA,UAAA,KACA,aAAA,EACA,cAAA,EACA,WAAA,KAGF,UACE,QAAA,MACA,QAAA,MAAA,KAGA,MAAA,QACA,gBAAA,KdHI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,YAIA,uCcPN,UdQQ,WAAA,McCN,gBAAA,gBAEE,MAAA,QAKF,mBACE,MAAA,QACA,eAAA,KACA,OAAA,QAQJ,UACE,cAAA,IAAA,MAAA,QAEA,oBACE,cAAA,KACA,WAAA,IACA,OAAA,IAAA,MAAA,Y7BlBA,uBAAA,OACA,wBAAA,O6BoBA,0BAAA,0BAEE,aAAA,QAAA,QAAA,QAEA,UAAA,QAGF,6BACE,MAAA,QACA,iBAAA,YACA,aAAA,Y/BixHN,mC+B7wHE,2BAEE,MAAA,QACA,iBAAA,KACA,aAAA,QAAA,QAAA,KAGF,yBAEE,WAAA,K7B5CA,uBAAA,EACA,wBAAA,E6BuDF,qBACE,WAAA,IACA,OAAA,E7BnEA,cAAA,O6BuEF,4B/BmwHF,2B+BjwHI,MAAA,KbxFF,iBAAA,QlB+1HF,oB+B5vHE,oBAEE,KAAA,EAAA,EAAA,KACA,WAAA,O/B+vHJ,yB+B1vHE,yBAEE,WAAA,EACA,UAAA,EACA,WAAA,OAMF,8B/BuvHF,mC+BtvHI,MAAA,KAUF,uBACE,QAAA,KAEF,qBACE,QAAA,MCxHJ,QACE,SAAA,SACA,QAAA,KACA,UAAA,KACA,YAAA,OACA,gBAAA,cACA,YAAA,MAEA,eAAA,MAOA,mBhCs2HF,yBAGA,sBADA,sBADA,sBAGA,sBACA,uBgC12HI,QAAA,KACA,UAAA,QACA,YAAA,OACA,gBAAA,cAoBJ,cACE,YAAA,SACA,eAAA,SACA,aAAA,K/B2OI,UAAA,Q+BzOJ,gBAAA,KACA,YAAA,OAaF,YACE,QAAA,KACA,eAAA,OACA,aAAA,EACA,cAAA,EACA,WAAA,KAEA,sBACE,cAAA,EACA,aAAA,EAGF,2BACE,SAAA,OASJ,aACE,YAAA,MACA,eAAA,MAYF,iBACE,WAAA,KACA,UAAA,EAGA,YAAA,OAIF,gBACE,QAAA,OAAA,O/B6KI,UAAA,Q+B3KJ,YAAA,EACA,iBAAA,YACA,OAAA,IAAA,MAAA,Y9BzGE,cAAA,OeHE,WAAA,WAAA,KAAA,YAIA,uCemGN,gBflGQ,WAAA,Me2GN,sBACE,gBAAA,KAGF,sBACE,gBAAA,KACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAMJ,qBACE,QAAA,aACA,MAAA,MACA,OAAA,MACA,eAAA,OACA,kBAAA,UACA,oBAAA,OACA,gBAAA,KAGF,mBACE,WAAA,6BACA,WAAA,KvB1FE,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhC+yHV,oCgC7yHQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCo2HV,oCgCl2HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCy5HV,oCgCv5HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,0BuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhC88HV,oCgC58HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,0BuBsGA,mBAEI,UAAA,OACA,gBAAA,WAEA,+BACE,eAAA,IAEA,8CACE,SAAA,SAGF,yCACE,cAAA,MACA,aAAA,MAIJ,sCACE,SAAA,QAGF,oCACE,QAAA,eACA,WAAA,KAGF,mCACE,QAAA,KAGF,qCACE,QAAA,KAGF,8BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCmgIV,qCgCjgIQ,kCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,mCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SA1DN,eAEI,UAAA,OACA,gBAAA,WAEA,2BACE,eAAA,IAEA,0CACE,SAAA,SAGF,qCACE,cAAA,MACA,aAAA,MAIJ,kCACE,SAAA,QAGF,gCACE,QAAA,eACA,WAAA,KAGF,+BACE,QAAA,KAGF,iCACE,QAAA,KAGF,0BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCujIV,iCgCrjIQ,8BAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,+BACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,QAcR,4BACE,MAAA,eAEA,kCAAA,kCAEE,MAAA,eAKF,oCACE,MAAA,gBAEA,0CAAA,0CAEE,MAAA,eAGF,6CACE,MAAA,ehCqiIR,2CgCjiII,0CAEE,MAAA,eAIJ,8BACE,MAAA,gBACA,aAAA,eAGF,mCACE,iBAAA,4OAGF,2BACE,MAAA,gBAEA,6BhC8hIJ,mCADA,mCgC1hIM,MAAA,eAOJ,2BACE,MAAA,KAEA,iCAAA,iCAEE,MAAA,KAKF,mCACE,MAAA,sBAEA,yCAAA,yCAEE,MAAA,sBAGF,4CACE,MAAA,sBhCqhIR,0CgCjhII,yCAEE,MAAA,KAIJ,6BACE,MAAA,sBACA,aAAA,qBAGF,kCACE,iBAAA,kPAGF,0BACE,MAAA,sBACA,4BhC+gIJ,kCADA,kCgC3gIM,MAAA,KCvUN,MACE,SAAA,SACA,QAAA,KACA,eAAA,OACA,UAAA,EAEA,UAAA,WACA,iBAAA,KACA,gBAAA,WACA,OAAA,IAAA,MAAA,iB/BME,cAAA,O+BFF,SACE,aAAA,EACA,YAAA,EAGF,kBACE,WAAA,QACA,cAAA,QAEA,8BACE,iBAAA,E/BCF,uBAAA,mBACA,wBAAA,mB+BEA,6BACE,oBAAA,E/BUF,2BAAA,mBACA,0BAAA,mB+BJF,+BjCk1IF,+BiCh1II,WAAA,EAIJ,WAGE,KAAA,EAAA,EAAA,KACA,QAAA,KAAA,KAIF,YACE,cAAA,MAGF,eACE,WAAA,QACA,cAAA,EAGF,sBACE,cAAA,EAQA,sBACE,YAAA,KAQJ,aACE,QAAA,MAAA,KACA,cAAA,EAEA,iBAAA,gBACA,cAAA,IAAA,MAAA,iBAEA,yB/BpEE,cAAA,mBAAA,mBAAA,EAAA,E+ByEJ,aACE,QAAA,MAAA,KAEA,iBAAA,gBACA,WAAA,IAAA,MAAA,iBAEA,wB/B/EE,cAAA,EAAA,EAAA,mBAAA,mB+ByFJ,kBACE,aAAA,OACA,cAAA,OACA,YAAA,OACA,cAAA,EAUF,mBACE,aAAA,OACA,YAAA,OAIF,kBACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,K/BnHE,cAAA,mB+BuHJ,UjCozIA,iBADA,ciChzIE,MAAA,KAGF,UjCmzIA,cEv6II,uBAAA,mBACA,wBAAA,mB+BwHJ,UjCozIA,iBE/5II,2BAAA,mBACA,0BAAA,mB+BuHF,kBACE,cAAA,OxBpGA,yBwBgGJ,YAQI,QAAA,KACA,UAAA,IAAA,KAGA,kBAEE,KAAA,EAAA,EAAA,GACA,cAAA,EAEA,wBACE,YAAA,EACA,YAAA,EAKA,mC/BpJJ,wBAAA,EACA,2BAAA,EF+7IJ,gDiCzyIU,iDAGE,wBAAA,EjC0yIZ,gDiCxyIU,oDAGE,2BAAA,EAIJ,oC/BrJJ,uBAAA,EACA,0BAAA,EF67IJ,iDiCtyIU,kDAGE,uBAAA,EjCuyIZ,iDiCryIU,qDAGE,0BAAA,GC7MZ,kBACE,SAAA,SACA,QAAA,KACA,YAAA,OACA,MAAA,KACA,QAAA,KAAA,QjC4RI,UAAA,KiC1RJ,MAAA,QACA,WAAA,KACA,iBAAA,KACA,OAAA,EhCKE,cAAA,EgCHF,gBAAA,KjBAI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,WAAA,CAAA,cAAA,KAAA,KAIA,uCiBhBN,kBjBiBQ,WAAA,MiBFN,kCACE,MAAA,QACA,iBAAA,QACA,WAAA,MAAA,EAAA,KAAA,EAAA,iBAEA,yCACE,iBAAA,gRACA,UAAA,gBAKJ,yBACE,YAAA,EACA,MAAA,QACA,OAAA,QACA,YAAA,KACA,QAAA,GACA,iBAAA,gRACA,kBAAA,UACA,gBAAA,QjBvBE,WAAA,UAAA,IAAA,YAIA,uCiBWJ,yBjBVM,WAAA,MiBsBN,wBACE,QAAA,EAGF,wBACE,QAAA,EACA,aAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,kBACE,cAAA,EAGF,gBACE,iBAAA,KACA,OAAA,IAAA,MAAA,iBAEA,8BhCnCE,uBAAA,OACA,wBAAA,OgCqCA,gDhCtCA,uBAAA,mBACA,wBAAA,mBgC0CF,oCACE,WAAA,EAIF,6BhClCE,2BAAA,OACA,0BAAA,OgCqCE,yDhCtCF,2BAAA,mBACA,0BAAA,mBgC0CA,iDhC3CA,2BAAA,OACA,0BAAA,OgCgDJ,gBACE,QAAA,KAAA,QASA,qCACE,aAAA,EAGF,iCACE,aAAA,EACA,YAAA,EhCxFA,cAAA,EgC2FA,6CAAgB,WAAA,EAChB,4CAAe,cAAA,EAEf,mDhC9FA,cAAA,EiCnBJ,YACE,QAAA,KACA,UAAA,KACA,QAAA,EAAA,EACA,cAAA,KAEA,WAAA,KAOA,kCACE,aAAA,MAEA,0CACE,MAAA,KACA,cAAA,MACA,MAAA,QACA,QAAA,kCAIJ,wBACE,MAAA,QCzBJ,YACE,QAAA,KhCGA,aAAA,EACA,WAAA,KgCAF,WACE,SAAA,SACA,QAAA,MACA,MAAA,QACA,gBAAA,KACA,iBAAA,KACA,OAAA,IAAA,MAAA,QnBKI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCmBfN,WnBgBQ,WAAA,MmBPN,iBACE,QAAA,EACA,MAAA,QAEA,iBAAA,QACA,aAAA,QAGF,iBACE,QAAA,EACA,MAAA,QACA,iBAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKF,wCACE,YAAA,KAGF,6BACE,QAAA,EACA,MAAA,KlBlCF,iBAAA,QkBoCE,aAAA,QAGF,+BACE,MAAA,QACA,eAAA,KACA,iBAAA,KACA,aAAA,QC3CF,WACE,QAAA,QAAA,OAOI,kCnCqCJ,uBAAA,OACA,0BAAA,OmChCI,iCnCiBJ,wBAAA,OACA,2BAAA,OmChCF,0BACE,QAAA,OAAA,OpCgSE,UAAA,QoCzRE,iDnCqCJ,uBAAA,MACA,0BAAA,MmChCI,gDnCiBJ,wBAAA,MACA,2BAAA,MmChCF,0BACE,QAAA,OAAA,MpCgSE,UAAA,QoCzRE,iDnCqCJ,uBAAA,MACA,0BAAA,MmChCI,gDnCiBJ,wBAAA,MACA,2BAAA,MoC/BJ,OACE,QAAA,aACA,QAAA,MAAA,MrC8RI,UAAA,MqC5RJ,YAAA,IACA,YAAA,EACA,MAAA,KACA,WAAA,OACA,YAAA,OACA,eAAA,SpCKE,cAAA,OoCAF,aACE,QAAA,KAKJ,YACE,SAAA,SACA,IAAA,KCvBF,OACE,SAAA,SACA,QAAA,KAAA,KACA,cAAA,KACA,OAAA,IAAA,MAAA,YrCWE,cAAA,OqCNJ,eAEE,MAAA,QAIF,YACE,YAAA,IAQF,mBACE,cAAA,KAGA,8BACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,QAAA,EACA,QAAA,QAAA,KAeF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,iBClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,6BACE,MAAA,QD6CF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,YClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,wBACE,MAAA,QD6CF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,cClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,0BACE,MAAA,QD6CF,aClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,yBACE,MAAA,QD6CF,YClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,wBACE,MAAA,QCHF,wCACE,GAAK,sBAAA,MADP,gCACE,GAAK,sBAAA,MAKT,UACE,QAAA,KACA,OAAA,KACA,SAAA,OxCwRI,UAAA,OwCtRJ,iBAAA,QvCIE,cAAA,OuCCJ,cACE,QAAA,KACA,eAAA,OACA,gBAAA,OACA,SAAA,OACA,MAAA,KACA,WAAA,OACA,YAAA,OACA,iBAAA,QxBZI,WAAA,MAAA,IAAA,KAIA,uCwBAN,cxBCQ,WAAA,MwBWR,sBvBYE,iBAAA,iKuBVA,gBAAA,KAAA,KAIA,uBACE,kBAAA,GAAA,OAAA,SAAA,qBAAA,UAAA,GAAA,OAAA,SAAA,qBAGE,uCAJJ,uBAKM,kBAAA,KAAA,UAAA,MCvCR,YACE,QAAA,KACA,eAAA,OAGA,aAAA,EACA,cAAA,ExCSE,cAAA,OwCLJ,qBACE,gBAAA,KACA,cAAA,QAEA,gCAEE,QAAA,uBAAA,KACA,kBAAA,QAUJ,wBACE,MAAA,KACA,MAAA,QACA,WAAA,QAGA,8BAAA,8BAEE,QAAA,EACA,MAAA,QACA,gBAAA,KACA,iBAAA,QAGF,+BACE,MAAA,QACA,iBAAA,QASJ,iBACE,SAAA,SACA,QAAA,MACA,QAAA,MAAA,KACA,MAAA,QACA,gBAAA,KACA,iBAAA,KACA,OAAA,IAAA,MAAA,iBAEA,6BxCrCE,uBAAA,QACA,wBAAA,QwCwCF,4BxC3BE,2BAAA,QACA,0BAAA,QwC8BF,0BAAA,0BAEE,MAAA,QACA,eAAA,KACA,iBAAA,KAIF,wBACE,QAAA,EACA,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,kCACE,iBAAA,EAEA,yCACE,WAAA,KACA,iBAAA,IAcF,uBACE,eAAA,IAGE,oDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,mDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,+CACE,WAAA,EAGF,yDACE,iBAAA,IACA,kBAAA,EAEA,gEACE,YAAA,KACA,kBAAA,IjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,0BiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,0BiC4CA,2BACE,eAAA,IAGE,wDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,uDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,mDACE,WAAA,EAGF,6DACE,iBAAA,IACA,kBAAA,EAEA,oEACE,YAAA,KACA,kBAAA,KAcZ,kBxC9HI,cAAA,EwCiIF,mCACE,aAAA,EAAA,EAAA,IAEA,8CACE,oBAAA,ECpJJ,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,2BACE,MAAA,QACA,iBAAA,QAGE,wDAAA,wDAEE,MAAA,QACA,iBAAA,QAGF,yDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,sBACE,MAAA,QACA,iBAAA,QAGE,mDAAA,mDAEE,MAAA,QACA,iBAAA,QAGF,oDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,wBACE,MAAA,QACA,iBAAA,QAGE,qDAAA,qDAEE,MAAA,QACA,iBAAA,QAGF,sDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,uBACE,MAAA,QACA,iBAAA,QAGE,oDAAA,oDAEE,MAAA,QACA,iBAAA,QAGF,qDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,sBACE,MAAA,QACA,iBAAA,QAGE,mDAAA,mDAEE,MAAA,QACA,iBAAA,QAGF,oDACE,MAAA,KACA,iBAAA,QACA,aAAA,QCbR,WACE,WAAA,YACA,MAAA,IACA,OAAA,IACA,QAAA,MAAA,MACA,MAAA,KACA,WAAA,YAAA,0TAAA,MAAA,CAAA,IAAA,KAAA,UACA,OAAA,E1COE,cAAA,O0CLF,QAAA,GAGA,iBACE,MAAA,KACA,gBAAA,KACA,QAAA,IAGF,iBACE,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBACA,QAAA,EAGF,oBAAA,oBAEE,eAAA,KACA,oBAAA,KAAA,iBAAA,KAAA,YAAA,KACA,QAAA,IAIJ,iBACE,OAAA,UAAA,gBAAA,iBCtCF,OACE,MAAA,MACA,UAAA,K5CmSI,UAAA,Q4ChSJ,eAAA,KACA,iBAAA,sBACA,gBAAA,YACA,OAAA,IAAA,MAAA,eACA,WAAA,EAAA,MAAA,KAAA,gB3CUE,cAAA,O2CPF,eACE,QAAA,EAGF,kBACE,QAAA,KAIJ,iBACE,MAAA,oBAAA,MAAA,iBAAA,MAAA,YACA,UAAA,KACA,eAAA,KAEA,mCACE,cAAA,OAIJ,cACE,QAAA,KACA,YAAA,OACA,QAAA,MAAA,OACA,MAAA,QACA,iBAAA,sBACA,gBAAA,YACA,cAAA,IAAA,MAAA,gB3CVE,uBAAA,mBACA,wBAAA,mB2CYF,yBACE,aAAA,SACA,YAAA,OAIJ,YACE,QAAA,OACA,UAAA,WC1CF,OACE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,QAAA,KACA,MAAA,KACA,OAAA,KACA,WAAA,OACA,WAAA,KAGA,QAAA,EAOF,cACE,SAAA,SACA,MAAA,KACA,OAAA,MAEA,eAAA,KAGA,0B7BlBI,WAAA,UAAA,IAAA,S6BoBF,UAAA,mB7BhBE,uC6BcJ,0B7BbM,WAAA,M6BiBN,0BACE,UAAA,KAIF,kCACE,UAAA,YAIJ,yBACE,OAAA,kBAEA,wCACE,WAAA,KACA,SAAA,OAGF,qCACE,WAAA,KAIJ,uBACE,QAAA,KACA,YAAA,OACA,WAAA,kBAIF,eACE,SAAA,SACA,QAAA,KACA,eAAA,OACA,MAAA,KAGA,eAAA,KACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,e5C3DE,cAAA,M4C+DF,QAAA,EAIF,gBCpFE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,MAAA,MACA,OAAA,MACA,iBAAA,KAGA,qBAAS,QAAA,EACT,qBAAS,QAAA,GDgFX,cACE,QAAA,KACA,YAAA,EACA,YAAA,OACA,gBAAA,cACA,QAAA,KAAA,KACA,cAAA,IAAA,MAAA,Q5CtEE,uBAAA,kBACA,wBAAA,kB4CwEF,yBACE,QAAA,MAAA,MACA,OAAA,OAAA,OAAA,OAAA,KAKJ,aACE,cAAA,EACA,YAAA,IAKF,YACE,SAAA,SAGA,KAAA,EAAA,EAAA,KACA,QAAA,KAIF,cACE,QAAA,KACA,UAAA,KACA,YAAA,EACA,YAAA,OACA,gBAAA,SACA,QAAA,OACA,WAAA,IAAA,MAAA,Q5CzFE,2BAAA,kBACA,0BAAA,kB4C8FF,gBACE,OAAA,OrC3EA,yBqCkFF,cACE,UAAA,MACA,OAAA,QAAA,KAGF,yBACE,OAAA,oBAGF,uBACE,WAAA,oBAOF,UAAY,UAAA,OrCnGV,yBqCuGF,U9CywKF,U8CvwKI,UAAA,OrCzGA,0BqC8GF,UAAY,UAAA,QASV,kBACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,iCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,gC5C/KF,cAAA,E4CmLE,8BACE,WAAA,KAGF,gC5CvLF,cAAA,EOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,6BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,6BqC0GA,2BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,0CACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,yC5C/KF,cAAA,E4CmLE,uCACE,WAAA,KAGF,yC5CvLF,cAAA,G8ClBJ,SACE,SAAA,SACA,QAAA,KACA,QAAA,MACA,OAAA,ECJA,YAAA,0BAEA,WAAA,OACA,YAAA,IACA,YAAA,IACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,OACA,WAAA,OACA,aAAA,OACA,YAAA,OACA,WAAA,KhDsRI,UAAA,Q+C1RJ,UAAA,WACA,QAAA,EAEA,cAAS,QAAA,GAET,wBACE,SAAA,SACA,QAAA,MACA,MAAA,MACA,OAAA,MAEA,gCACE,SAAA,SACA,QAAA,GACA,aAAA,YACA,aAAA,MAKN,6CAAA,gBACE,QAAA,MAAA,EAEA,4DAAA,+BACE,OAAA,EAEA,oEAAA,uCACE,IAAA,KACA,aAAA,MAAA,MAAA,EACA,iBAAA,KAKN,+CAAA,gBACE,QAAA,EAAA,MAEA,8DAAA,+BACE,KAAA,EACA,MAAA,MACA,OAAA,MAEA,sEAAA,uCACE,MAAA,KACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,KAKN,gDAAA,mBACE,QAAA,MAAA,EAEA,+DAAA,kCACE,IAAA,EAEA,uEAAA,0CACE,OAAA,KACA,aAAA,EAAA,MAAA,MACA,oBAAA,KAKN,8CAAA,kBACE,QAAA,EAAA,MAEA,6DAAA,iCACE,MAAA,EACA,MAAA,MACA,OAAA,MAEA,qEAAA,yCACE,KAAA,KACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,KAqBN,eACE,UAAA,MACA,QAAA,OAAA,MACA,MAAA,KACA,WAAA,OACA,iBAAA,K9C7FE,cAAA,OgDnBJ,SACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,QAAA,MACA,UAAA,MDLA,YAAA,0BAEA,WAAA,OACA,YAAA,IACA,YAAA,IACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,OACA,WAAA,OACA,aAAA,OACA,YAAA,OACA,WAAA,KhDsRI,UAAA,QiDzRJ,UAAA,WACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,ehDIE,cAAA,MgDAF,wBACE,SAAA,SACA,QAAA,MACA,MAAA,KACA,OAAA,MAEA,+BAAA,gCAEE,SAAA,SACA,QAAA,MACA,QAAA,GACA,aAAA,YACA,aAAA,MAMJ,4DAAA,+BACE,OAAA,mBAEA,oEAAA,uCACE,OAAA,EACA,aAAA,MAAA,MAAA,EACA,iBAAA,gBAGF,mEAAA,sCACE,OAAA,IACA,aAAA,MAAA,MAAA,EACA,iBAAA,KAMJ,8DAAA,+BACE,KAAA,mBACA,MAAA,MACA,OAAA,KAEA,sEAAA,uCACE,KAAA,EACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,gBAGF,qEAAA,sCACE,KAAA,IACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,KAMJ,+DAAA,kCACE,IAAA,mBAEA,uEAAA,0CACE,IAAA,EACA,aAAA,EAAA,MAAA,MAAA,MACA,oBAAA,gBAGF,sEAAA,yCACE,IAAA,IACA,aAAA,EAAA,MAAA,MAAA,MACA,oBAAA,KAKJ,wEAAA,2CACE,SAAA,SACA,IAAA,EACA,KAAA,IACA,QAAA,MACA,MAAA,KACA,YAAA,OACA,QAAA,GACA,cAAA,IAAA,MAAA,QAKF,6DAAA,iCACE,MAAA,mBACA,MAAA,MACA,OAAA,KAEA,qEAAA,yCACE,MAAA,EACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,gBAGF,oEAAA,wCACE,MAAA,IACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,KAqBN,gBACE,QAAA,MAAA,KACA,cAAA,EjDuJI,UAAA,KiDpJJ,iBAAA,QACA,cAAA,IAAA,MAAA,ehDtHE,uBAAA,kBACA,wBAAA,kBgDwHF,sBACE,QAAA,KAIJ,cACE,QAAA,KAAA,KACA,MAAA,QC/IF,UACE,SAAA,SAGF,wBACE,aAAA,MAGF,gBACE,SAAA,SACA,MAAA,KACA,SAAA,OCtBA,uBACE,QAAA,MACA,MAAA,KACA,QAAA,GDuBJ,eACE,SAAA,SACA,QAAA,KACA,MAAA,KACA,MAAA,KACA,aAAA,MACA,4BAAA,OAAA,oBAAA,OlClBI,WAAA,UAAA,IAAA,YAIA,uCkCQN,elCPQ,WAAA,MjBgzLR,oBACA,oBmDhyLA,sBAGE,QAAA,MnDmyLF,0BmD/xLA,8CAEE,UAAA,iBnDkyLF,4BmD/xLA,4CAEE,UAAA,kBAWA,8BACE,QAAA,EACA,oBAAA,QACA,UAAA,KnD0xLJ,uDACA,qDmDxxLE,qCAGE,QAAA,EACA,QAAA,EnDyxLJ,yCmDtxLE,2CAEE,QAAA,EACA,QAAA,ElC/DE,WAAA,QAAA,GAAA,IAIA,uCjBq1LN,yCmD7xLE,2ClCvDM,WAAA,MjB01LR,uBmDtxLA,uBAEE,SAAA,SACA,IAAA,EACA,OAAA,EACA,QAAA,EAEA,QAAA,KACA,YAAA,OACA,gBAAA,OACA,MAAA,IACA,QAAA,EACA,MAAA,KACA,WAAA,OACA,WAAA,IACA,OAAA,EACA,QAAA,GlCzFI,WAAA,QAAA,KAAA,KAIA,uCjB82LN,uBmDzyLA,uBlCpEQ,WAAA,MjBm3LR,6BADA,6BmD1xLE,6BAAA,6BAEE,MAAA,KACA,gBAAA,KACA,QAAA,EACA,QAAA,GAGJ,uBACE,KAAA,EAGF,uBACE,MAAA,EnD8xLF,4BmDzxLA,4BAEE,QAAA,aACA,MAAA,KACA,OAAA,KACA,kBAAA,UACA,oBAAA,IACA,gBAAA,KAAA,KAWF,4BACE,iBAAA,wPAEF,4BACE,iBAAA,yPAQF,qBACE,SAAA,SACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,EACA,QAAA,KACA,gBAAA,OACA,QAAA,EAEA,aAAA,IACA,cAAA,KACA,YAAA,IACA,WAAA,KAEA,sCACE,WAAA,YACA,KAAA,EAAA,EAAA,KACA,MAAA,KACA,OAAA,IACA,QAAA,EACA,aAAA,IACA,YAAA,IACA,YAAA,OACA,OAAA,QACA,iBAAA,KACA,gBAAA,YACA,OAAA,EAEA,WAAA,KAAA,MAAA,YACA,cAAA,KAAA,MAAA,YACA,QAAA,GlC5KE,WAAA,QAAA,IAAA,KAIA,uCkCwJJ,sClCvJM,WAAA,MkC2KN,6BACE,QAAA,EASJ,kBACE,SAAA,SACA,MAAA,IACA,OAAA,QACA,KAAA,IACA,YAAA,QACA,eAAA,QACA,MAAA,KACA,WAAA,OnDoxLF,2CmD9wLE,2CAEE,OAAA,UAAA,eAGF,qDACE,iBAAA,KAGF,iCACE,MAAA,KE7NJ,kCACE,GAAK,UAAA,gBADP,0BACE,GAAK,UAAA,gBAIP,gBACE,QAAA,aACA,MAAA,KACA,OAAA,KACA,eAAA,QACA,OAAA,MAAA,MAAA,aACA,mBAAA,YAEA,cAAA,IACA,kBAAA,KAAA,OAAA,SAAA,eAAA,UAAA,KAAA,OAAA,SAAA,eAGF,mBACE,MAAA,KACA,OAAA,KACA,aAAA,KAQF,gCACE,GACE,UAAA,SAEF,IACE,QAAA,EACA,UAAA,MANJ,wBACE,GACE,UAAA,SAEF,IACE,QAAA,EACA,UAAA,MAKJ,cACE,QAAA,aACA,MAAA,KACA,OAAA,KACA,eAAA,QACA,iBAAA,aAEA,cAAA,IACA,QAAA,EACA,kBAAA,KAAA,OAAA,SAAA,aAAA,UAAA,KAAA,OAAA,SAAA,aAGF,iBACE,MAAA,KACA,OAAA,KAIA,uCACE,gBrDo/LJ,cqDl/LM,2BAAA,KAAA,mBAAA,MCjEN,WACE,SAAA,MACA,OAAA,EACA,QAAA,KACA,QAAA,KACA,eAAA,OACA,UAAA,KAEA,WAAA,OACA,iBAAA,KACA,gBAAA,YACA,QAAA,ErCKI,WAAA,UAAA,IAAA,YAIA,uCqCpBN,WrCqBQ,WAAA,MqCLR,oBPdE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,MAAA,MACA,OAAA,MACA,iBAAA,KAGA,yBAAS,QAAA,EACT,yBAAS,QAAA,GOQX,kBACE,QAAA,KACA,YAAA,OACA,gBAAA,cACA,QAAA,KAAA,KAEA,6BACE,QAAA,MAAA,MACA,WAAA,OACA,aAAA,OACA,cAAA,OAIJ,iBACE,cAAA,EACA,YAAA,IAGF,gBACE,UAAA,EACA,QAAA,KAAA,KACA,WAAA,KAGF,iBACE,IAAA,EACA,KAAA,EACA,MAAA,MACA,aAAA,IAAA,MAAA,eACA,UAAA,kBAGF,eACE,IAAA,EACA,MAAA,EACA,MAAA,MACA,YAAA,IAAA,MAAA,eACA,UAAA,iBAGF,eACE,IAAA,EACA,MAAA,EACA,KAAA,EACA,OAAA,KACA,WAAA,KACA,cAAA,IAAA,MAAA,eACA,UAAA,kBAGF,kBACE,MAAA,EACA,KAAA,EACA,OAAA,KACA,WAAA,KACA,WAAA,IAAA,MAAA,eACA,UAAA,iBAGF,gBACE,UAAA,KCjFF,aACE,QAAA,aACA,WAAA,IACA,eAAA,OACA,OAAA,KACA,iBAAA,aACA,QAAA,GAEA,yBACE,QAAA,aACA,QAAA,GAKJ,gBACE,WAAA,KAGF,gBACE,WAAA,KAGF,gBACE,WAAA,MAKA,+BACE,kBAAA,iBAAA,GAAA,YAAA,SAAA,UAAA,iBAAA,GAAA,YAAA,SAIJ,oCACE,IACE,QAAA,IAFJ,4BACE,IACE,QAAA,IAIJ,kBACE,mBAAA,8DAAA,WAAA,8DACA,kBAAA,KAAA,KAAA,UAAA,KAAA,KACA,kBAAA,iBAAA,GAAA,OAAA,SAAA,UAAA,iBAAA,GAAA,OAAA,SAGF,oCACE,KACE,sBAAA,MAAA,GAAA,cAAA,MAAA,IAFJ,4BACE,KACE,sBAAA,MAAA,GAAA,cAAA,MAAA,IH9CF,iBACE,QAAA,MACA,MAAA,KACA,QAAA,GIJF,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,gBACE,MAAA,QAGE,sBAAA,sBAEE,MAAA,QANN,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,WACE,MAAA,QAGE,iBAAA,iBAEE,MAAA,QANN,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,aACE,MAAA,QAGE,mBAAA,mBAEE,MAAA,QANN,YACE,MAAA,QAGE,kBAAA,kBAEE,MAAA,QANN,WACE,MAAA,QAGE,iBAAA,iBAEE,MAAA,QCLR,OACE,SAAA,SACA,MAAA,KAEA,eACE,QAAA,MACA,YAAA,uBACA,QAAA,GAGF,SACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,MAAA,KACA,OAAA,KAKF,WACE,kBAAA,KADF,WACE,kBAAA,mBADF,YACE,kBAAA,oBADF,YACE,kBAAA,oBCrBJ,WACE,SAAA,MACA,IAAA,EACA,MAAA,EACA,KAAA,EACA,QAAA,KAGF,cACE,SAAA,MACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,KAQE,YACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,KjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,0BiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,0BiDxCA,gBACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MCzBN,QACE,QAAA,KACA,eAAA,IACA,YAAA,OACA,WAAA,QAGF,QACE,QAAA,KACA,KAAA,EAAA,EAAA,KACA,eAAA,OACA,WAAA,QCRF,iB5Dk4MA,0D6D93ME,SAAA,mBACA,MAAA,cACA,OAAA,cACA,QAAA,YACA,OAAA,eACA,SAAA,iBACA,KAAA,wBACA,YAAA,iBACA,OAAA,YCXA,uBACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,EACA,QAAA,GCRJ,eCAE,SAAA,OACA,cAAA,SACA,YAAA,OCNF,IACE,QAAA,aACA,WAAA,QACA,MAAA,IACA,WAAA,IACA,iBAAA,aACA,QAAA,ICyDM,gBAOI,eAAA,mBAPJ,WAOI,eAAA,cAPJ,cAOI,eAAA,iBAPJ,cAOI,eAAA,iBAPJ,mBAOI,eAAA,sBAPJ,gBAOI,eAAA,mBAPJ,aAOI,MAAA,eAPJ,WAOI,MAAA,gBAPJ,YAOI,MAAA,eAPJ,WAOI,QAAA,YAPJ,YAOI,QAAA,cAPJ,YAOI,QAAA,aAPJ,YAOI,QAAA,cAPJ,aAOI,QAAA,YAPJ,eAOI,SAAA,eAPJ,iBAOI,SAAA,iBAPJ,kBAOI,SAAA,kBAPJ,iBAOI,SAAA,iBAPJ,UAOI,QAAA,iBAPJ,gBAOI,QAAA,uBAPJ,SAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,SAOI,QAAA,gBAPJ,aAOI,QAAA,oBAPJ,cAOI,QAAA,qBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,QAOI,QAAA,eAPJ,QAOI,WAAA,EAAA,MAAA,KAAA,0BAPJ,WAOI,WAAA,EAAA,QAAA,OAAA,2BAPJ,WAOI,WAAA,EAAA,KAAA,KAAA,2BAPJ,aAOI,WAAA,eAPJ,iBAOI,SAAA,iBAPJ,mBAOI,SAAA,mBAPJ,mBAOI,SAAA,mBAPJ,gBAOI,SAAA,gBAPJ,iBAOI,SAAA,yBAAA,SAAA,iBAPJ,OAOI,IAAA,YAPJ,QAOI,IAAA,cAPJ,SAOI,IAAA,eAPJ,UAOI,OAAA,YAPJ,WAOI,OAAA,cAPJ,YAOI,OAAA,eAPJ,SAOI,KAAA,YAPJ,UAOI,KAAA,cAPJ,WAOI,KAAA,eAPJ,OAOI,MAAA,YAPJ,QAOI,MAAA,cAPJ,SAOI,MAAA,eAPJ,kBAOI,UAAA,+BAPJ,oBAOI,UAAA,2BAPJ,oBAOI,UAAA,2BAPJ,QAOI,OAAA,IAAA,MAAA,kBAPJ,UAOI,OAAA,YAPJ,YAOI,WAAA,IAAA,MAAA,kBAPJ,cAOI,WAAA,YAPJ,YAOI,aAAA,IAAA,MAAA,kBAPJ,cAOI,aAAA,YAPJ,eAOI,cAAA,IAAA,MAAA,kBAPJ,iBAOI,cAAA,YAPJ,cAOI,YAAA,IAAA,MAAA,kBAPJ,gBAOI,YAAA,YAPJ,gBAOI,aAAA,kBAPJ,kBAOI,aAAA,kBAPJ,gBAOI,aAAA,kBAPJ,aAOI,aAAA,kBAPJ,gBAOI,aAAA,kBAPJ,eAOI,aAAA,kBAPJ,cAOI,aAAA,kBAPJ,aAOI,aAAA,kBAPJ,cAOI,aAAA,eAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,MAOI,MAAA,cAPJ,MAOI,MAAA,cAPJ,MAOI,MAAA,cAPJ,OAOI,MAAA,eAPJ,QAOI,MAAA,eAPJ,QAOI,UAAA,eAPJ,QAOI,MAAA,gBAPJ,YAOI,UAAA,gBAPJ,MAOI,OAAA,cAPJ,MAOI,OAAA,cAPJ,MAOI,OAAA,cAPJ,OAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,QAOI,WAAA,eAPJ,QAOI,OAAA,gBAPJ,YAOI,WAAA,gBAPJ,WAOI,KAAA,EAAA,EAAA,eAPJ,UAOI,eAAA,cAPJ,aAOI,eAAA,iBAPJ,kBAOI,eAAA,sBAPJ,qBAOI,eAAA,yBAPJ,aAOI,UAAA,YAPJ,aAOI,UAAA,YAPJ,eAOI,YAAA,YAPJ,eAOI,YAAA,YAPJ,WAOI,UAAA,eAPJ,aAOI,UAAA,iBAPJ,mBAOI,UAAA,uBAPJ,OAOI,IAAA,YAPJ,OAOI,IAAA,iBAPJ,OAOI,IAAA,gBAPJ,OAOI,IAAA,eAPJ,OAOI,IAAA,iBAPJ,OAOI,IAAA,eAPJ,uBAOI,gBAAA,qBAPJ,qBAOI,gBAAA,mBAPJ,wBAOI,gBAAA,iBAPJ,yBAOI,gBAAA,wBAPJ,wBAOI,gBAAA,uBAPJ,wBAOI,gBAAA,uBAPJ,mBAOI,YAAA,qBAPJ,iBAOI,YAAA,mBAPJ,oBAOI,YAAA,iBAPJ,sBAOI,YAAA,mBAPJ,qBAOI,YAAA,kBAPJ,qBAOI,cAAA,qBAPJ,mBAOI,cAAA,mBAPJ,sBAOI,cAAA,iBAPJ,uBAOI,cAAA,wBAPJ,sBAOI,cAAA,uBAPJ,uBAOI,cAAA,kBAPJ,iBAOI,WAAA,eAPJ,kBAOI,WAAA,qBAPJ,gBAOI,WAAA,mBAPJ,mBAOI,WAAA,iBAPJ,qBAOI,WAAA,mBAPJ,oBAOI,WAAA,kBAPJ,aAOI,MAAA,aAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,KAOI,OAAA,YAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,gBAPJ,KAOI,OAAA,eAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,MAOI,aAAA,YAAA,YAAA,YAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,gBAAA,YAAA,gBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,WAAA,YAAA,cAAA,YAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,gBAAA,cAAA,gBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,YAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,gBAPJ,MAOI,WAAA,eAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,eAPJ,SAOI,WAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,SAOI,aAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,SAOI,cAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,SAOI,YAAA,eAPJ,KAOI,QAAA,YAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,gBAPJ,KAOI,QAAA,eAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,eAPJ,MAOI,cAAA,YAAA,aAAA,YAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,gBAAA,aAAA,gBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,YAAA,YAAA,eAAA,YAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,gBAAA,eAAA,gBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,MAOI,eAAA,YAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,gBAPJ,MAOI,eAAA,eAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,gBAOI,YAAA,mCAPJ,MAOI,UAAA,iCAPJ,MAOI,UAAA,gCAPJ,MAOI,UAAA,8BAPJ,MAOI,UAAA,gCAPJ,MAOI,UAAA,kBAPJ,MAOI,UAAA,eAPJ,YAOI,WAAA,iBAPJ,YAOI,WAAA,iBAPJ,UAOI,YAAA,cAPJ,YAOI,YAAA,kBAPJ,WAOI,YAAA,cAPJ,SAOI,YAAA,cAPJ,WAOI,YAAA,iBAPJ,MAOI,YAAA,YAPJ,OAOI,YAAA,eAPJ,SAOI,YAAA,cAPJ,OAOI,YAAA,YAPJ,YAOI,WAAA,eAPJ,UAOI,WAAA,gBAPJ,aAOI,WAAA,iBAPJ,sBAOI,gBAAA,eAPJ,2BAOI,gBAAA,oBAPJ,8BAOI,gBAAA,uBAPJ,gBAOI,eAAA,oBAPJ,gBAOI,eAAA,oBAPJ,iBAOI,eAAA,qBAPJ,WAOI,YAAA,iBAPJ,aAOI,YAAA,iBAPJ,YAOI,UAAA,qBAAA,WAAA,qBAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,gBAIQ,kBAAA,EAGJ,MAAA,+DAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,aAIQ,kBAAA,EAGJ,MAAA,4DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,YAIQ,kBAAA,EAGJ,MAAA,kBAPJ,eAIQ,kBAAA,EAGJ,MAAA,yBAPJ,eAIQ,kBAAA,EAGJ,MAAA,+BAPJ,YAIQ,kBAAA,EAGJ,MAAA,kBAjBJ,iBACE,kBAAA,KADF,iBACE,kBAAA,IADF,iBACE,kBAAA,KADF,kBACE,kBAAA,EASF,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,cAIQ,gBAAA,EAGJ,iBAAA,6DAPJ,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,WAIQ,gBAAA,EAGJ,iBAAA,0DAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,gBAIQ,gBAAA,EAGJ,iBAAA,sBAjBJ,eACE,gBAAA,IADF,eACE,gBAAA,KADF,eACE,gBAAA,IADF,eACE,gBAAA,KADF,gBACE,gBAAA,EASF,aAOI,iBAAA,6BAPJ,iBAOI,oBAAA,cAAA,iBAAA,cAAA,YAAA,cAPJ,kBAOI,oBAAA,eAAA,iBAAA,eAAA,YAAA,eAPJ,kBAOI,oBAAA,eAAA,iBAAA,eAAA,YAAA,eAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,iBAPJ,WAOI,cAAA,YAPJ,WAOI,cAAA,gBAPJ,WAOI,cAAA,iBAPJ,WAOI,cAAA,gBAPJ,gBAOI,cAAA,cAPJ,cAOI,cAAA,gBAPJ,aAOI,uBAAA,iBAAA,wBAAA,iBAPJ,aAOI,wBAAA,iBAAA,2BAAA,iBAPJ,gBAOI,2BAAA,iBAAA,0BAAA,iBAPJ,eAOI,0BAAA,iBAAA,uBAAA,iBAPJ,SAOI,WAAA,kBAPJ,WAOI,WAAA,iBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,0ByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,0ByDAI,iBAOI,MAAA,eAPJ,eAOI,MAAA,gBAPJ,gBAOI,MAAA,eAPJ,cAOI,QAAA,iBAPJ,oBAOI,QAAA,uBAPJ,aAOI,QAAA,gBAPJ,YAOI,QAAA,eAPJ,aAOI,QAAA,gBAPJ,iBAOI,QAAA,oBAPJ,kBAOI,QAAA,qBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,YAOI,QAAA,eAPJ,eAOI,KAAA,EAAA,EAAA,eAPJ,cAOI,eAAA,cAPJ,iBAOI,eAAA,iBAPJ,sBAOI,eAAA,sBAPJ,yBAOI,eAAA,yBAPJ,iBAOI,UAAA,YAPJ,iBAOI,UAAA,YAPJ,mBAOI,YAAA,YAPJ,mBAOI,YAAA,YAPJ,eAOI,UAAA,eAPJ,iBAOI,UAAA,iBAPJ,uBAOI,UAAA,uBAPJ,WAOI,IAAA,YAPJ,WAOI,IAAA,iBAPJ,WAOI,IAAA,gBAPJ,WAOI,IAAA,eAPJ,WAOI,IAAA,iBAPJ,WAOI,IAAA,eAPJ,2BAOI,gBAAA,qBAPJ,yBAOI,gBAAA,mBAPJ,4BAOI,gBAAA,iBAPJ,6BAOI,gBAAA,wBAPJ,4BAOI,gBAAA,uBAPJ,4BAOI,gBAAA,uBAPJ,uBAOI,YAAA,qBAPJ,qBAOI,YAAA,mBAPJ,wBAOI,YAAA,iBAPJ,0BAOI,YAAA,mBAPJ,yBAOI,YAAA,kBAPJ,yBAOI,cAAA,qBAPJ,uBAOI,cAAA,mBAPJ,0BAOI,cAAA,iBAPJ,2BAOI,cAAA,wBAPJ,0BAOI,cAAA,uBAPJ,2BAOI,cAAA,kBAPJ,qBAOI,WAAA,eAPJ,sBAOI,WAAA,qBAPJ,oBAOI,WAAA,mBAPJ,uBAOI,WAAA,iBAPJ,yBAOI,WAAA,mBAPJ,wBAOI,WAAA,kBAPJ,iBAOI,MAAA,aAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,gBAOI,MAAA,YAPJ,SAOI,OAAA,YAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,gBAPJ,SAOI,OAAA,eAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,eAPJ,YAOI,OAAA,eAPJ,UAOI,aAAA,YAAA,YAAA,YAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,gBAAA,YAAA,gBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,aAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,WAAA,YAAA,cAAA,YAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,gBAAA,cAAA,gBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,aAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,YAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,gBAPJ,UAOI,WAAA,eAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,eAPJ,aAOI,WAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,aAOI,aAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,aAOI,cAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,aAOI,YAAA,eAPJ,SAOI,QAAA,YAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,gBAPJ,SAOI,QAAA,eAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,eAPJ,UAOI,cAAA,YAAA,aAAA,YAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,gBAAA,aAAA,gBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,YAAA,YAAA,eAAA,YAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,gBAAA,eAAA,gBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,UAOI,eAAA,YAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,gBAPJ,UAOI,eAAA,eAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,gBAOI,WAAA,eAPJ,cAOI,WAAA,gBAPJ,iBAOI,WAAA,kBCnDZ,0BD4CQ,MAOI,UAAA,iBAPJ,MAOI,UAAA,eAPJ,MAOI,UAAA,kBAPJ,MAOI,UAAA,kBChCZ,aDyBQ,gBAOI,QAAA,iBAPJ,sBAOI,QAAA,uBAPJ,eAOI,QAAA,gBAPJ,cAOI,QAAA,eAPJ,eAOI,QAAA,gBAPJ,mBAOI,QAAA,oBAPJ,oBAOI,QAAA,qBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,cAOI,QAAA","sourcesContent":["/*!\n * Bootstrap v5.1.0 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-rgb: #{to-rgb($body-color)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n font-size: var(--#{$variable-prefix}-root-font-size);\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`