From e2aef670995186910a564c2386ed2aa02be536da Mon Sep 17 00:00:00 2001 From: spaethf Date: Tue, 29 Sep 2026 18:08:01 +0200 Subject: [PATCH] QR-Scanner: Kamera-Fallbacks und Foto-Upload; weitere Aenderungen --- Lagerbestand/Components/Pages/Customers.razor | 235 ++++++++++++++++++ .../Components/Pages/Customers.razor.css | 126 ++++++++++ .../Components/Pages/StorageLocations.razor | 77 +++++- .../Pages/StorageLocations.razor.css | 71 +++++- .../Components/Shared/QrScannerModal.razor | 26 ++ Lagerbestand/Lagerbestand.csproj | 1 + Lagerbestand/Program.cs | 5 + Lagerbestand/Services/CustomerMergeService.cs | 161 ++++++++++++ Lagerbestand/Services/LexwareSyncService.cs | 31 +-- Lagerbestand/Services/StorageRowPdfService.cs | 184 ++++++++++++++ Lagerbestand/Services/TireStackService.cs | 6 +- Lagerbestand/wwwroot/js/app-interop.js | 25 ++ Lagerbestand/wwwroot/js/qr-scanner.js | 75 ++++-- 13 files changed, 965 insertions(+), 58 deletions(-) create mode 100644 Lagerbestand/Services/CustomerMergeService.cs create mode 100644 Lagerbestand/Services/StorageRowPdfService.cs diff --git a/Lagerbestand/Components/Pages/Customers.razor b/Lagerbestand/Components/Pages/Customers.razor index 477e3b0..e8bc4fb 100644 --- a/Lagerbestand/Components/Pages/Customers.razor +++ b/Lagerbestand/Components/Pages/Customers.razor @@ -3,6 +3,7 @@ @inject ICustomerService CustomerService @inject ITireStackService TireStackService @inject ILexwareSyncService LexwareSyncService +@inject ICustomerMergeService CustomerMergeService @inject ISiteSettingsService SiteSettingsService @inject UserSessionService UserSession @inject IJSRuntime JS @@ -187,6 +188,12 @@ else + @if (lexwareEnabled && CustomerMergeService.IsLocalCustomer(customer)) + { + + } Reifenstapel einlagern @@ -329,6 +336,88 @@ else } + +@* Merge local customer into Lexware customer *@ +@if (showMergeModal && mergeSource != null) +{ + + +} } @code { @@ -362,6 +451,152 @@ else private bool CanSave => !string.IsNullOrWhiteSpace(editCustomer.LastName); + // Merge state + private bool showMergeModal; + private Customer? mergeSource; + private Customer? mergeTarget; + private string mergeSearch = ""; + private List mergeCandidates = new(); + private HashSet mergeSuggestionIds = new(); + private int mergeVehicleCount; + private int mergeStackCount; + private bool mergeConfirmed; + private bool isMerging; + private string? mergeError; + + private async Task OpenMerge(Customer customer) + { + mergeSource = customer; + mergeTarget = null; + mergeSearch = ""; + mergeConfirmed = false; + mergeError = null; + + mergeVehicleCount = customer.Vehicles.Count(v => v.IsActive); + if (!customerTireStacks.TryGetValue(customer.Id, out var stacks)) + { + stacks = await TireStackService.GetByCustomerAsync(customer.Id); + customerTireStacks[customer.Id] = stacks; + } + mergeStackCount = stacks.Count; + + var lastName = customer.LastName.Trim().ToLowerInvariant(); + var phone = NormalizePhone(customer.Phone); + var email = customer.Email?.Trim().ToLowerInvariant(); + + mergeSuggestionIds = allCustomers + .Where(c => c.Id != customer.Id && !CustomerMergeService.IsLocalCustomer(c)) + .Where(c => + (lastName.Length > 0 && c.LastName.Trim().ToLowerInvariant() == lastName) || + (phone.Length > 0 && NormalizePhone(c.Phone) == phone) || + (!string.IsNullOrEmpty(email) && c.Email?.Trim().ToLowerInvariant() == email)) + .Select(c => c.Id) + .ToHashSet(); + + FilterMergeCandidates(); + showMergeModal = true; + } + + private void FilterMergeCandidates() + { + if (mergeSource == null) return; + + var term = mergeSearch.Trim().ToLowerInvariant(); + IEnumerable query = allCustomers + .Where(c => c.Id != mergeSource.Id && !CustomerMergeService.IsLocalCustomer(c)); + + if (term.Length > 0) + { + query = query.Where(c => + c.FullName.ToLowerInvariant().Contains(term) || + (c.Company?.ToLowerInvariant().Contains(term) ?? false) || + c.CustomerNumber.ToLowerInvariant().Contains(term) || + (c.Phone?.Contains(term) ?? false) || + (c.Email?.ToLowerInvariant().Contains(term) ?? false)); + } + + mergeCandidates = query + .OrderByDescending(c => mergeSuggestionIds.Contains(c.Id)) + .ThenBy(c => c.LastName) + .ThenBy(c => c.FirstName) + .Take(term.Length > 0 ? 50 : 20) + .ToList(); + + if (mergeTarget != null && mergeCandidates.All(c => c.Id != mergeTarget.Id)) + { + mergeTarget = null; + mergeConfirmed = false; + } + } + + private void SelectMergeTarget(Customer customer) + { + mergeTarget = customer; + mergeConfirmed = false; + mergeError = null; + } + + private void CloseMerge() + { + if (isMerging) return; + showMergeModal = false; + mergeSource = null; + mergeTarget = null; + mergeCandidates = new(); + mergeSuggestionIds = new(); + mergeError = null; + } + + private async Task ExecuteMerge() + { + if (mergeSource == null || mergeTarget == null || !mergeConfirmed) return; + + isMerging = true; + mergeError = null; + + try + { + var result = await CustomerMergeService.MergeAsync(mergeSource.Id, mergeTarget.Id); + + customerTireStacks.Remove(mergeSource.Id); + customerTireStacks.Remove(mergeTarget.Id); + + syncMessage = $"Kunde {mergeSource.CustomerNumber} wurde mit {mergeTarget.DisplayName} ({mergeTarget.CustomerNumber}) zusammengeführt: " + + $"{result.VehiclesMoved} Fahrzeug(e), {result.TireStacksMoved} Reifenstapel, {result.TasksMoved} Aufgabe(n) übertragen."; + syncMessageType = "success"; + + var targetId = result.TargetCustomerId; + isMerging = false; + CloseMerge(); + + await LoadCustomers(); + SearchCustomers(); + expandedId = targetId; + await ToggleCustomerLoadOnly(targetId); + } + catch (Exception ex) + { + mergeError = $"Zusammenführen fehlgeschlagen: {ex.Message}"; + } + finally + { + isMerging = false; + } + } + + private async Task ToggleCustomerLoadOnly(Guid id) + { + if (!customerTireStacks.ContainsKey(id)) + { + customerTireStacks[id] = await TireStackService.GetByCustomerAsync(id); + } + } + + private static string NormalizePhone(string? phone) => + string.IsNullOrWhiteSpace(phone) + ? "" + : new string(phone.Where(char.IsDigit).ToArray()); + protected override async Task OnInitializedAsync() { if (UserSession.IsAdmin) diff --git a/Lagerbestand/Components/Pages/Customers.razor.css b/Lagerbestand/Components/Pages/Customers.razor.css index f487096..c37f500 100644 --- a/Lagerbestand/Components/Pages/Customers.razor.css +++ b/Lagerbestand/Components/Pages/Customers.razor.css @@ -741,4 +741,130 @@ margin-bottom: 0.5rem; } +/* ======================================== + Merge Modal + ======================================== */ +.merge-source { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + margin-bottom: 1rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.merge-source .label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary, #9a9a9a); +} + +.merge-number { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8rem; + color: var(--text-secondary, #9a9a9a); +} + +.merge-candidates { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + max-height: 260px; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; +} + +.merge-candidate { + padding: 0.55rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background-color 0.15s ease; +} + +.merge-candidate:last-child { + border-bottom: none; +} + +.merge-candidate:hover { + background: rgba(255, 255, 255, 0.05); +} + +.merge-candidate.selected { + background: rgba(var(--primary-rgb, 59, 130, 246), 0.18); + box-shadow: inset 3px 0 0 var(--primary, #3b82f6); +} + +.merge-candidate-main { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} + +.merge-candidate-sub { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary, #9a9a9a); + margin-top: 0.15rem; +} + +.merge-suggest-badge { + font-size: 0.7rem; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: rgba(34, 197, 94, 0.18); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.35); +} + +.merge-empty { + padding: 0.75rem; + font-size: 0.85rem; + color: var(--text-secondary, #9a9a9a); + text-align: center; +} + +.merge-summary { + margin-top: 1rem; + padding: 0.75rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.9rem; +} + +.merge-summary p { + margin: 0 0 0.5rem; +} + +.merge-summary ul { + margin: 0 0 0.5rem 1.1rem; + padding: 0; +} + +.merge-warning { + color: #f87171; +} + +.merge-confirm { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 0.85rem; + cursor: pointer; +} + +.merge-confirm input { + margin-top: 0.2rem; + flex-shrink: 0; +} + /* Reduced motion: inherited from app.css (section 17) */ diff --git a/Lagerbestand/Components/Pages/StorageLocations.razor b/Lagerbestand/Components/Pages/StorageLocations.razor index ac2c250..31e57b0 100644 --- a/Lagerbestand/Components/Pages/StorageLocations.razor +++ b/Lagerbestand/Components/Pages/StorageLocations.razor @@ -4,6 +4,8 @@ @inject ILocationCodeService LocationCodeService @inject ITireStackService TireStackService @inject UserSessionService UserSession +@inject IStorageRowPdfService RowPdfService +@inject IJSRuntime JS Lagerplätze - Reifen Späth Lagerverwaltung @@ -52,7 +54,7 @@ else +
+ + +
+ @if (printErrorRowKey == rowKey && printError != null) + { + + } @if (!isCollapsed) { @foreach (var posGroup in rowGroup.GroupBy(l => l.Position).OrderBy(g => (int)g.Key)) @@ -155,11 +170,21 @@ else @if (location.IsOccupied && storedTireStack != null) {
- + @(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager") + @if (storedTireStack.Vehicle != null) + { + + 🚗 @storedTireStack.Vehicle.ShortName + + } - @(storedTireStack.Season == TireSeason.Sommer ? "☀️" : storedTireStack.Season == TireSeason.Winter ? "❄️" : "🔄") + @(storedTireStack.Season == TireSeason.Sommer ? "☀️" : storedTireStack.Season == TireSeason.Winter ? "❄️" : "🔄") + @if (!string.IsNullOrEmpty(storedTireStack.TireSize)) + { + @storedTireStack.TireSize + }
} @@ -231,7 +256,7 @@ else @storedTireStack.Customer.FullName @if (storedTireStack.Vehicle != null) { - @storedTireStack.Vehicle.ShortName + 🚗 @storedTireStack.Vehicle.ShortName } } else @@ -761,6 +786,40 @@ else } } + private string? printingRowKey; + private string? printErrorRowKey; + private string? printError; + + private async Task PrintRow(AreaType area, int row) + { + var rowKey = $"{area}-{row}"; + if (printingRowKey != null) return; + + printingRowKey = rowKey; + printError = null; + printErrorRowKey = null; + + try + { + var rowLocations = allLocations + .Where(l => l.Area == area && l.Row == row) + .ToList(); + + var pdf = RowPdfService.GenerateRowPdf(area, row, rowLocations); + var fileName = $"Lagerplan_{area.GetDisplayName()}_Reihe{row}_{DateTime.Now:yyyyMMdd-HHmm}.pdf"; + await JS.InvokeVoidAsync("appInterop.downloadFile", fileName, Convert.ToBase64String(pdf), "application/pdf"); + } + catch (Exception ex) + { + printErrorRowKey = rowKey; + printError = $"PDF konnte nicht erstellt werden: {ex.Message}"; + } + finally + { + printingRowKey = null; + } + } + // Batch creation private AreaType batchArea = AreaType.Lager; private int batchRow = 1; diff --git a/Lagerbestand/Components/Pages/StorageLocations.razor.css b/Lagerbestand/Components/Pages/StorageLocations.razor.css index f5277f3..44c0ac3 100644 --- a/Lagerbestand/Components/Pages/StorageLocations.razor.css +++ b/Lagerbestand/Components/Pages/StorageLocations.razor.css @@ -493,6 +493,33 @@ margin-bottom: 0; } +.row-header-wrap { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.row-header-wrap .row-header { + flex: 1; + min-width: 0; +} + +.row-print-btn { + flex-shrink: 0; + white-space: nowrap; + margin-bottom: 0.4rem; +} + +.row-section.collapsed .row-print-btn { + margin-bottom: 0; +} + +.row-print-error { + color: var(--danger, #e74c3c); + font-size: 0.8rem; + margin: 0 0 0.4rem 1.75rem; +} + .row-chevron { font-size: 0.6rem; color: var(--text-secondary); @@ -629,10 +656,48 @@ text-overflow: ellipsis; } -.card-content .tire-info { - display: block; - font-size: var(--text-sm); +.card-content .vehicle-line { + display: flex; + align-items: center; + gap: 4px; margin-top: 2px; + min-width: 0; +} + +.card-content .vehicle-model { + font-size: 0.7rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.location-table .vehicle-info { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.card-content .tire-info { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + margin-top: 3px; + white-space: nowrap; + overflow: hidden; +} + +.card-content .tire-size-compact { + font-size: 0.65rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; } .card-content.empty { diff --git a/Lagerbestand/Components/Shared/QrScannerModal.razor b/Lagerbestand/Components/Shared/QrScannerModal.razor index f13f40a..805480d 100644 --- a/Lagerbestand/Components/Shared/QrScannerModal.razor +++ b/Lagerbestand/Components/Shared/QrScannerModal.razor @@ -32,6 +32,11 @@
QR-Code des Reports vor die Kamera halten
} +
+ + +
+
@@ -53,6 +58,7 @@ [Parameter] public EventCallback OnScanned { get; set; } private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}"; + private readonly string _fileInputId = $"qr-file-{Guid.NewGuid():N}"; private DotNetObjectReference? _selfRef; private bool _scannerActive; private bool _wasOpen; @@ -127,6 +133,26 @@ await OnScanned.InvokeAsync(text.Trim()); } + private async Task OnFileSelected(ChangeEventArgs e) + { + cameraError = null; + _scannerActive = false; + try + { + var text = await JS.InvokeAsync("qrScanner.scanFile", _elementId, _fileInputId); + if (!string.IsNullOrWhiteSpace(text)) + { + await OnQrScanned(text); + return; + } + } + catch (JSException ex) + { + cameraError = ExtractMessage(ex.Message); + } + StateHasChanged(); + } + private async Task SubmitManual() { if (string.IsNullOrWhiteSpace(manualText)) return; diff --git a/Lagerbestand/Lagerbestand.csproj b/Lagerbestand/Lagerbestand.csproj index 2be4f12..99e0e22 100644 --- a/Lagerbestand/Lagerbestand.csproj +++ b/Lagerbestand/Lagerbestand.csproj @@ -12,6 +12,7 @@ + diff --git a/Lagerbestand/Program.cs b/Lagerbestand/Program.cs index 56b4a33..ffadfdb 100644 --- a/Lagerbestand/Program.cs +++ b/Lagerbestand/Program.cs @@ -128,6 +128,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Launch TTS01 tire report (QR-Code) lookup builder.Services.AddHttpClient("LaunchReport", client => client.Timeout = TimeSpan.FromSeconds(15)); @@ -141,6 +142,10 @@ builder.Services.AddHttpClient("LexwareApi"); builder.Services.AddScoped(); builder.Services.AddHostedService(); +// PDF export (QuestPDF Community license) +QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; +builder.Services.AddSingleton(); + var app = builder.Build(); // Configure the HTTP request pipeline diff --git a/Lagerbestand/Services/CustomerMergeService.cs b/Lagerbestand/Services/CustomerMergeService.cs new file mode 100644 index 0000000..40f1c43 --- /dev/null +++ b/Lagerbestand/Services/CustomerMergeService.cs @@ -0,0 +1,161 @@ +using Lagerbestand.Data; +using Lagerbestand.Models; +using Microsoft.EntityFrameworkCore; + +namespace Lagerbestand.Services; + +/// +/// Result of merging a local customer into a Lexware customer. +/// +public class CustomerMergeResult +{ + public Guid TargetCustomerId { get; set; } + public int VehiclesMoved { get; set; } + public int TireStacksMoved { get; set; } + public int TasksMoved { get; set; } +} + +public interface ICustomerMergeService +{ + /// + /// Local customers are created by this app and use the "K-" number prefix. + /// Lexware customers carry the numeric Lexware customer number. + /// + bool IsLocalCustomer(Customer customer); + + /// + /// Moves all vehicles, tire stacks and task links from a locally created customer + /// to an existing Lexware customer and permanently deletes the local customer. + /// + Task MergeAsync(Guid sourceId, Guid targetId); +} + +public class CustomerMergeService : ICustomerMergeService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public CustomerMergeService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public static bool IsLocalCustomerNumber(string? customerNumber) => + string.IsNullOrWhiteSpace(customerNumber) || + customerNumber.StartsWith("K-", StringComparison.OrdinalIgnoreCase); + + public bool IsLocalCustomer(Customer customer) => IsLocalCustomerNumber(customer.CustomerNumber); + + public async Task MergeAsync(Guid sourceId, Guid targetId) + { + if (sourceId == targetId) + throw new InvalidOperationException("Quell- und Zielkunde dürfen nicht identisch sein."); + + var strategy = _context.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + + var source = await _context.Customers.FirstOrDefaultAsync(c => c.Id == sourceId) + ?? throw new InvalidOperationException("Lokaler Kunde wurde nicht gefunden."); + var target = await _context.Customers.FirstOrDefaultAsync(c => c.Id == targetId) + ?? throw new InvalidOperationException("Lexware-Kunde wurde nicht gefunden."); + + if (!IsLocalCustomerNumber(source.CustomerNumber)) + throw new InvalidOperationException("Nur lokal angelegte Kunden (K-…) können zusammengeführt werden."); + if (IsLocalCustomerNumber(target.CustomerNumber)) + throw new InvalidOperationException("Der Zielkunde muss ein Lexware-Kunde sein."); + + var result = new CustomerMergeResult { TargetCustomerId = targetId }; + var now = DateTime.UtcNow; + + // Vehicles + var targetHasPrimary = await _context.Vehicles + .AnyAsync(v => v.CustomerId == targetId && v.IsActive && v.IsPrimary); + var vehicles = await _context.Vehicles + .Where(v => v.CustomerId == sourceId) + .ToListAsync(); + + foreach (var v in vehicles) + { + v.CustomerId = targetId; + if (targetHasPrimary && v.IsPrimary) + v.IsPrimary = false; + v.UpdatedAt = now; + } + result.VehiclesMoved = vehicles.Count; + + // Tire stacks (including history / picked-up ones) + var stacks = await _context.TireStacks + .Where(ts => ts.CustomerId == sourceId) + .ToListAsync(); + + foreach (var ts in stacks) + { + ts.CustomerId = targetId; + ts.UpdatedAt = now; + } + result.TireStacksMoved = stacks.Count; + + // Kanban task links (composite key TaskId + CustomerId -> recreate) + var taskLinks = await _context.KanbanTaskCustomers + .Where(tc => tc.CustomerId == sourceId) + .ToListAsync(); + + if (taskLinks.Count > 0) + { + var taskIds = taskLinks.Select(tc => tc.TaskId).ToList(); + var existingTargetTaskIds = (await _context.KanbanTaskCustomers + .Where(tc => tc.CustomerId == targetId && taskIds.Contains(tc.TaskId)) + .Select(tc => tc.TaskId) + .ToListAsync()).ToHashSet(); + + foreach (var link in taskLinks) + { + if (!existingTargetTaskIds.Contains(link.TaskId)) + { + _context.KanbanTaskCustomers.Add(new KanbanTaskCustomer + { + TaskId = link.TaskId, + CustomerId = targetId, + VehicleId = link.VehicleId + }); + result.TasksMoved++; + } + _context.KanbanTaskCustomers.Remove(link); + } + } + + // Fill empty contact data on target from source + var targetChanged = false; + if (string.IsNullOrWhiteSpace(target.Phone) && !string.IsNullOrWhiteSpace(source.Phone)) { target.Phone = source.Phone; targetChanged = true; } + if (string.IsNullOrWhiteSpace(target.Email) && !string.IsNullOrWhiteSpace(source.Email)) { target.Email = source.Email; targetChanged = true; } + if (!string.IsNullOrWhiteSpace(source.Notes)) + { + var mergedNotes = string.IsNullOrWhiteSpace(target.Notes) + ? source.Notes + : $"{target.Notes}\n[Zusammengeführt aus {source.CustomerNumber}] {source.Notes}"; + if (mergedNotes.Length > 500) mergedNotes = mergedNotes[..500]; + target.Notes = mergedNotes; + targetChanged = true; + } + if (!target.IsActive) { target.IsActive = true; targetChanged = true; } + if (targetChanged) target.UpdatedAt = now; + + // Persist FK changes first, then hard-delete the source customer + await _context.SaveChangesAsync(); + _context.Customers.Remove(source); + await _context.SaveChangesAsync(); + + await transaction.CommitAsync(); + + _logger.LogInformation( + "Customer {SourceNumber} merged into {TargetNumber}: {Vehicles} vehicles, {Stacks} tire stacks, {Tasks} task links", + source.CustomerNumber, target.CustomerNumber, result.VehiclesMoved, result.TireStacksMoved, result.TasksMoved); + + return result; + }); + } +} diff --git a/Lagerbestand/Services/LexwareSyncService.cs b/Lagerbestand/Services/LexwareSyncService.cs index 710faf6..6847a89 100644 --- a/Lagerbestand/Services/LexwareSyncService.cs +++ b/Lagerbestand/Services/LexwareSyncService.cs @@ -16,7 +16,6 @@ public class LexwareSyncResult 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; } } @@ -324,9 +323,6 @@ public class LexwareSyncService : ILexwareSyncService 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; @@ -357,36 +353,17 @@ public class LexwareSyncService : ILexwareSyncService 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); + // Note: customers not present in Lexware are intentionally left untouched. + // The local IsActive flag is managed exclusively within this application. // Update sync timestamp settings.LastSyncAt = DateTime.UtcNow; settings.LastSyncCount = result.TotalProcessed; - settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert"; + settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert"; await _siteSettingsService.SaveLexwareApiSettingsAsync(settings); result.Success = true; - result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert ({result.TotalProcessed} verarbeitet)"; + result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert ({result.TotalProcessed} verarbeitet)"; _logger.LogInformation("Lexware sync completed: {Message}", result.Message); } diff --git a/Lagerbestand/Services/StorageRowPdfService.cs b/Lagerbestand/Services/StorageRowPdfService.cs new file mode 100644 index 0000000..755217c --- /dev/null +++ b/Lagerbestand/Services/StorageRowPdfService.cs @@ -0,0 +1,184 @@ +using Lagerbestand.Models; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace Lagerbestand.Services; + +public interface IStorageRowPdfService +{ + /// + /// Generates a printable A4 inventory sheet for one shelf row (Area + Row). + /// + byte[] GenerateRowPdf(AreaType area, int row, IReadOnlyList locations); +} + +public class StorageRowPdfService : IStorageRowPdfService +{ + private static readonly string HeaderBackground = Colors.Grey.Lighten3; + private static readonly string BorderColor = Colors.Grey.Medium; + + public byte[] GenerateRowPdf(AreaType area, int row, IReadOnlyList locations) + { + var rowLocations = locations + .Where(l => l.Area == area && l.Row == row) + .ToList(); + + var occupied = rowLocations.Count(l => l.IsOccupied); + var total = StorageLocation.PositionsPerSlot * StorageLocation.MaxSlot; + var printedAt = DateTime.Now; + + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.DefaultTextStyle(x => x.FontSize(9)); + + page.Header() + .PaddingBottom(12) + .AlignCenter() + .Text($"{area.GetDisplayName()} – Regal {row}") + .Bold() + .FontSize(20); + + page.Content().Row(r => + { + r.RelativeItem().Column(col => + { + col.Spacing(14); + col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.LinksOben, rowLocations)); + col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.LinksUnten, rowLocations)); + }); + + r.ConstantItem(14); + + r.RelativeItem().Column(col => + { + col.Spacing(14); + col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.RechtsOben, rowLocations)); + col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.RechtsUnten, rowLocations)); + }); + }); + + page.Footer() + .PaddingTop(10) + .Row(f => + { + f.RelativeItem().Text($"Gedruckt am {printedAt:dd.MM.yyyy HH:mm}").FontSize(8).FontColor(Colors.Grey.Darken1); + f.RelativeItem().AlignRight().Text($"{occupied} / {total} belegt").FontSize(8).FontColor(Colors.Grey.Darken1); + }); + }); + }); + + return document.GeneratePdf(); + } + + private static void RenderPositionTable(IContainer container, AreaType area, int row, ShelfPosition position, List rowLocations) + { + var bySlot = rowLocations + .Where(l => l.Position == position) + .GroupBy(l => l.SlotNumber) + .ToDictionary(g => g.Key, g => g.First()); + + container.Column(col => + { + col.Spacing(4); + col.Item().Text(position.GetDisplayName()).Bold().FontSize(11); + + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(28); + columns.ConstantColumn(34); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + columns.ConstantColumn(44); + }); + + table.Header(header => + { + header.Cell().Element(HeaderCell).Text("Platz"); + header.Cell().Element(HeaderCell).Text("Code"); + header.Cell().Element(HeaderCell).Text("Kunde/Inhalt"); + header.Cell().Element(HeaderCell).Text("Fahrzeug"); + header.Cell().Element(HeaderCell).Text("Saison"); + }); + + for (var slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++) + { + bySlot.TryGetValue(slot, out var location); + var code = location?.Code ?? $"{(int)area}{row}{(int)position}{slot}"; + + string content, vehicle, season; + var muted = false; + + if (location is { IsOccupied: true, OccupiedTireStack: not null }) + { + var stack = location.OccupiedTireStack; + content = stack.Customer?.FullName ?? stack.CatalogItem?.Name ?? "Lager"; + vehicle = stack.Vehicle?.ShortName ?? "–"; + season = SeasonText(stack.Season); + } + else if (location is { IsOccupied: true }) + { + content = "Belegt"; + vehicle = "–"; + season = "–"; + } + else if (location is { IsAvailable: false }) + { + content = "Gesperrt"; + vehicle = "–"; + season = "–"; + muted = true; + } + else + { + content = "Frei"; + vehicle = "–"; + season = "–"; + muted = true; + } + + table.Cell().Element(BodyCell).Text(slot.ToString()); + table.Cell().Element(BodyCell).Text(code); + table.Cell().Element(BodyCell).Text(t => Styled(t.Span(content), muted)); + table.Cell().Element(BodyCell).Text(t => Styled(t.Span(vehicle), muted)); + table.Cell().Element(BodyCell).Text(t => Styled(t.Span(season), muted)); + } + }); + }); + } + + private static void Styled(TextSpanDescriptor span, bool muted) + { + if (muted) + span.FontColor(Colors.Grey.Medium).Italic(); + } + + private static string SeasonText(TireSeason season) => season switch + { + TireSeason.Sommer => "Sommer", + TireSeason.Winter => "Winter", + TireSeason.Allrad => "Ganzjahr", + _ => season.ToString() + }; + + private static IContainer HeaderCell(IContainer container) => container + .Background(HeaderBackground) + .Border(0.5f) + .BorderColor(BorderColor) + .PaddingVertical(3) + .PaddingHorizontal(4) + .DefaultTextStyle(x => x.Bold().FontSize(8)); + + private static IContainer BodyCell(IContainer container) => container + .Border(0.5f) + .BorderColor(BorderColor) + .PaddingVertical(3) + .PaddingHorizontal(4) + .DefaultTextStyle(x => x.FontSize(8)); +} diff --git a/Lagerbestand/Services/TireStackService.cs b/Lagerbestand/Services/TireStackService.cs index 05c6daf..272fb92 100644 --- a/Lagerbestand/Services/TireStackService.cs +++ b/Lagerbestand/Services/TireStackService.cs @@ -577,8 +577,8 @@ public class TireStackService : ITireStackService .Select(g => new { Season = g.Key, Count = g.Count() }) .ToListAsync(); - var totalLocations = await _context.StorageLocations - .Where(s => s.IsActive) + var availableLocations = await _context.StorageLocations + .Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied) .CountAsync(); var occupiedLocations = await _context.TireStacks @@ -598,7 +598,7 @@ public class TireStackService : ITireStackService 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, + AvailableLocations = availableLocations, OccupiedLocations = occupiedLocations, SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0, WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0, diff --git a/Lagerbestand/wwwroot/js/app-interop.js b/Lagerbestand/wwwroot/js/app-interop.js index 0bb4c4a..63cecb3 100644 --- a/Lagerbestand/wwwroot/js/app-interop.js +++ b/Lagerbestand/wwwroot/js/app-interop.js @@ -49,6 +49,31 @@ } catch (e) { /* ignore */ } }; + // --------------------------------------------------------- + // File download from base64 (used for PDF exports) + // --------------------------------------------------------- + window.appInterop.downloadFile = function (fileName, base64, mimeType) { + try { + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName || 'download'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + return true; + } catch (e) { + console.warn('[appInterop] downloadFile failed', e); + return false; + } + }; + // --------------------------------------------------------- // beforeunload: warn user when draft flag is set // --------------------------------------------------------- diff --git a/Lagerbestand/wwwroot/js/qr-scanner.js b/Lagerbestand/wwwroot/js/qr-scanner.js index 7d074a5..156f852 100644 --- a/Lagerbestand/wwwroot/js/qr-scanner.js +++ b/Lagerbestand/wwwroot/js/qr-scanner.js @@ -9,33 +9,43 @@ window.qrScanner = (function () { if (window.Html5Qrcode) return; await new Promise((resolve, reject) => { const s = document.createElement('script'); - s.src = 'lib/html5-qrcode/html5-qrcode.min.js'; + s.src = new URL('lib/html5-qrcode/html5-qrcode.min.js', document.baseURI).href; s.onload = resolve; s.onerror = () => reject(new Error('QR-Bibliothek konnte nicht geladen werden.')); document.head.appendChild(s); }); } + function createScanner(elementId) { + return new Html5Qrcode(elementId, { + formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE], + experimentalFeatures: { useBarCodeDetectorIfSupported: true }, + verbose: false + }); + } + + function pickBackCamera(cams) { + const back = cams.find(c => /back|rear|environment|rück|hinten/i.test(c.label || '')); + return (back || cams[cams.length - 1]).id; + } + async function start(elementId, dotNetRef) { await stop(); await ensureLib(); - if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { - throw new Error('Kamera wird von diesem Browser nicht unterstützt (HTTPS erforderlich).'); + if (!window.isSecureContext || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error('Live-Kamera nicht verfügbar (HTTPS erforderlich). Bitte „Foto aufnehmen“ verwenden.'); } - scanner = new Html5Qrcode(elementId, { - formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE], - verbose: false - }); + scanner = createScanner(elementId); const config = { fps: 10, qrbox: (w, h) => { - const size = Math.max(180, Math.min(w, h) * 0.7); - return { width: size, height: size }; + const size = Math.floor(Math.max(150, Math.min(w, h) * 0.7)); + return { width: Math.min(size, w), height: Math.min(size, h) }; }, - aspectRatio: 1.0 + disableFlip: false }; const onSuccess = (text) => { @@ -46,13 +56,46 @@ window.qrScanner = (function () { dotNetRef.invokeMethodAsync('OnQrScanned', text); }; + const attempts = [ + () => scanner.start({ facingMode: { exact: 'environment' } }, config, onSuccess, () => { }), + () => scanner.start({ facingMode: 'environment' }, config, onSuccess, () => { }), + async () => { + const cams = await Html5Qrcode.getCameras(); + if (!cams || cams.length === 0) throw new Error('Keine Kamera gefunden.'); + await scanner.start(pickBackCamera(cams), config, onSuccess, () => { }); + }, + () => scanner.start({ facingMode: 'user' }, config, onSuccess, () => { }) + ]; + + let lastError = null; + for (const attempt of attempts) { + try { + await attempt(); + return; + } catch (e) { + lastError = e; + if (e && (e.name === 'NotAllowedError' || /NotAllowed|Permission/i.test(String(e)))) break; + try { if (scanner.isScanning) await scanner.stop(); } catch { } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError || 'Kamera konnte nicht gestartet werden.')); + } + + // Decode a QR code from an image file (photo). Works on any device, also without HTTPS. + async function scanFile(elementId, inputId) { + await ensureLib(); + const input = document.getElementById(inputId); + const file = input && input.files && input.files[0]; + if (!file) return null; + await stop(); + const fileScanner = createScanner(elementId); try { - await scanner.start({ facingMode: 'environment' }, config, onSuccess, () => { }); + return await fileScanner.scanFile(file, false); } catch (e) { - // Fallback: any camera - const cams = await Html5Qrcode.getCameras(); - if (!cams || cams.length === 0) throw new Error('Keine Kamera gefunden.'); - await scanner.start(cams[0].id, config, onSuccess, () => { }); + throw new Error('Kein QR-Code im Bild erkannt.'); + } finally { + try { fileScanner.clear(); } catch { } + input.value = ''; } } @@ -84,5 +127,5 @@ window.qrScanner = (function () { } catch (e) { /* audio is best-effort */ } } - return { start, stop, playSound }; + return { start, stop, scanFile, playSound }; })();