-
+
+ @if (printErrorRowKey == rowKey && printError != null)
+ {
+
@printError
+ }
@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 };
})();