QR-Scanner: Kamera-Fallbacks und Foto-Upload; weitere Aenderungen

This commit is contained in:
spaethf committed 2026-09-29 18:08:01 +02:00
1 parent 7a0406eaa2
commit e2aef67099
13 files changed
+965 -58

No files matched your search

@@ -3,6 +3,7 @@
@inject ICustomerService CustomerService @inject ICustomerService CustomerService
@inject ITireStackService TireStackService @inject ITireStackService TireStackService
@inject ILexwareSyncService LexwareSyncService @inject ILexwareSyncService LexwareSyncService
@inject ICustomerMergeService CustomerMergeService
@inject ISiteSettingsService SiteSettingsService @inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession @inject UserSessionService UserSession
@inject IJSRuntime JS @inject IJSRuntime JS
@@ -187,6 +188,12 @@ else
<button class="btn btn-sm btn-outline" @onclick="() => EditCustomer(customer)"> <button class="btn btn-sm btn-outline" @onclick="() => EditCustomer(customer)">
Bearbeiten Bearbeiten
</button> </button>
@if (lexwareEnabled && CustomerMergeService.IsLocalCustomer(customer))
{
<button class="btn btn-sm btn-outline" @onclick="() => OpenMerge(customer)" title="Lokalen Kunden mit einem Lexware-Kunden zusammenführen">
Mit Lexware-Kunde zusammenführen
</button>
}
<a href="/tire-checkin" class="btn btn-sm btn-primary"> <a href="/tire-checkin" class="btn btn-sm btn-primary">
Reifenstapel einlagern Reifenstapel einlagern
</a> </a>
@@ -329,6 +336,88 @@ else
</div> </div>
</div> </div>
} }
@* Merge local customer into Lexware customer *@
@if (showMergeModal && mergeSource != null)
{
<div class="modal-backdrop" @onclick="CloseMerge"></div>
<div class="app-modal" @onclick="CloseMerge" role="dialog" aria-modal="true" aria-labelledby="merge-title">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="merge-title">Kunden zusammenführen</h5>
<button type="button" class="btn-close" @onclick="CloseMerge" aria-label="Dialog schließen"></button>
</div>
<div class="modal-body">
<div class="merge-source">
<span class="label">Lokaler Kunde</span>
<strong>@mergeSource.DisplayName</strong>
<span class="merge-number">@mergeSource.CustomerNumber</span>
</div>
<label class="form-label" for="merge-search">Lexware-Kunde auswählen</label>
<input id="merge-search" type="text" class="form-control" placeholder="Name, Firma, Kundennummer, Telefon…"
@bind="mergeSearch" @bind:event="oninput" @bind:after="FilterMergeCandidates" />
<ul class="merge-candidates" role="listbox">
@if (mergeCandidates.Count == 0)
{
<li class="merge-empty">Keine passenden Lexware-Kunden gefunden.</li>
}
@foreach (var c in mergeCandidates)
{
<li @key="c.Id" class="merge-candidate @(mergeTarget?.Id == c.Id ? "selected" : "")"
role="option" aria-selected="@(mergeTarget?.Id == c.Id)"
@onclick="() => SelectMergeTarget(c)">
<div class="merge-candidate-main">
<strong>@c.DisplayName</strong>
<span class="merge-number">@c.CustomerNumber</span>
@if (mergeSuggestionIds.Contains(c.Id))
{
<span class="merge-suggest-badge">Vorschlag</span>
}
</div>
<div class="merge-candidate-sub">
@if (!string.IsNullOrEmpty(c.Phone)) { <span>@c.Phone</span> }
@if (!string.IsNullOrEmpty(c.Email)) { <span>@c.Email</span> }
</div>
</li>
}
</ul>
@if (mergeTarget != null)
{
<div class="merge-summary">
<p>Folgende Daten werden auf <strong>@mergeTarget.DisplayName</strong> (@mergeTarget.CustomerNumber) übertragen:</p>
<ul>
<li>@mergeVehicleCount Fahrzeug(e)</li>
<li>@mergeStackCount Reifenstapel (inkl. Historie)</li>
<li>Verknüpfte Aufgaben und Notizen</li>
</ul>
<p class="merge-warning">Der lokale Kunde <strong>@mergeSource.CustomerNumber</strong> wird anschließend endgültig gelöscht.</p>
<label class="merge-confirm">
<input type="checkbox" @bind="mergeConfirmed" />
Ich habe verstanden, dass dieser Vorgang nicht rückgängig gemacht werden kann.
</label>
</div>
}
@if (!string.IsNullOrEmpty(mergeError))
{
<div class="alert alert-danger">@mergeError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @onclick="CloseMerge" disabled="@isMerging">Abbrechen</button>
<button type="button" class="btn btn-danger" @onclick="ExecuteMerge"
disabled="@(mergeTarget == null || !mergeConfirmed || isMerging)">
@(isMerging ? "Wird zusammengeführt…" : "Zusammenführen")
</button>
</div>
</div>
</div>
</div>
}
} }
@code { @code {
@@ -362,6 +451,152 @@ else
private bool CanSave => private bool CanSave =>
!string.IsNullOrWhiteSpace(editCustomer.LastName); !string.IsNullOrWhiteSpace(editCustomer.LastName);
// Merge state
private bool showMergeModal;
private Customer? mergeSource;
private Customer? mergeTarget;
private string mergeSearch = "";
private List<Customer> mergeCandidates = new();
private HashSet<Guid> 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<Customer> 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() protected override async Task OnInitializedAsync()
{ {
if (UserSession.IsAdmin) if (UserSession.IsAdmin)
@@ -741,4 +741,130 @@
margin-bottom: 0.5rem; 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) */ /* Reduced motion: inherited from app.css (section 17) */
@@ -4,6 +4,8 @@
@inject ILocationCodeService LocationCodeService @inject ILocationCodeService LocationCodeService
@inject ITireStackService TireStackService @inject ITireStackService TireStackService
@inject UserSessionService UserSession @inject UserSessionService UserSession
@inject IStorageRowPdfService RowPdfService
@inject IJSRuntime JS
<PageTitle>Lagerplätze - Reifen Späth Lagerverwaltung</PageTitle> <PageTitle>Lagerplätze - Reifen Späth Lagerverwaltung</PageTitle>
@@ -52,7 +54,7 @@ else
</button> </button>
<button class="stat-chip available @(statusFilter == StatusAvailable ? "active" : "")" <button class="stat-chip available @(statusFilter == StatusAvailable ? "active" : "")"
@onclick="FilterByStatusAvailable" title="Nur freie Plätze anzeigen"> @onclick="FilterByStatusAvailable" title="Nur freie Plätze anzeigen">
<span class="chip-value">@allLocations.Count(l => !l.IsOccupied)</span> Frei <span class="chip-value">@allLocations.Count(l => !l.IsOccupied && l.IsAvailable)</span> Frei
</button> </button>
<button class="stat-chip occupied @(statusFilter == StatusOccupied ? "active" : "")" <button class="stat-chip occupied @(statusFilter == StatusOccupied ? "active" : "")"
@onclick="FilterByStatusOccupied" title="Nur belegte Plätze anzeigen"> @onclick="FilterByStatusOccupied" title="Nur belegte Plätze anzeigen">
@@ -132,11 +134,24 @@ else
var occupiedCount = rowGroup.Count(l => l.IsOccupied); var occupiedCount = rowGroup.Count(l => l.IsOccupied);
var totalCount = rowGroup.Count(); var totalCount = rowGroup.Count();
<div class="row-section @(isCollapsed ? "collapsed" : "")"> <div class="row-section @(isCollapsed ? "collapsed" : "")">
<button type="button" class="row-header" @onclick="() => ToggleRow(rowKey)"> <div class="row-header-wrap">
<span class="row-chevron">@(isCollapsed ? "▶" : "▼")</span> <button type="button" class="row-header" @onclick="() => ToggleRow(rowKey)">
<h3 class="row-title">Reihe @rowGroup.Key</h3> <span class="row-chevron">@(isCollapsed ? "▶" : "▼")</span>
<span class="row-summary">@occupiedCount / @totalCount belegt</span> <h3 class="row-title">Reihe @rowGroup.Key</h3>
</button> <span class="row-summary">@occupiedCount / @totalCount belegt</span>
</button>
<button type="button" class="btn btn-outline btn-sm row-print-btn"
title="Reihe als PDF drucken"
disabled="@(printingRowKey != null)"
@onclick="() => PrintRow(areaGroup.Key, rowGroup.Key)"
@onclick:stopPropagation>
@(printingRowKey == rowKey ? "⏳" : "🖨") PDF
</button>
</div>
@if (printErrorRowKey == rowKey && printError != null)
{
<div class="row-print-error" role="alert">@printError</div>
}
@if (!isCollapsed) @if (!isCollapsed)
{ {
@foreach (var posGroup in rowGroup.GroupBy(l => l.Position).OrderBy(g => (int)g.Key)) @foreach (var posGroup in rowGroup.GroupBy(l => l.Position).OrderBy(g => (int)g.Key))
@@ -155,11 +170,21 @@ else
@if (location.IsOccupied && storedTireStack != null) @if (location.IsOccupied && storedTireStack != null)
{ {
<div class="card-content"> <div class="card-content">
<span class="customer-name"> <span class="customer-name" title="@(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager")">
@(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager") @(storedTireStack.Customer?.FullName ?? storedTireStack.CatalogItem?.Name ?? "Lager")
</span> </span>
@if (storedTireStack.Vehicle != null)
{
<span class="vehicle-line" title="@storedTireStack.Vehicle.ShortName">
<span class="vehicle-model">🚗 @storedTireStack.Vehicle.ShortName</span>
</span>
}
<span class="tire-info"> <span class="tire-info">
@(storedTireStack.Season == TireSeason.Sommer ? "☀️" : storedTireStack.Season == TireSeason.Winter ? "❄️" : "🔄") <span class="season-icon">@(storedTireStack.Season == TireSeason.Sommer ? "☀️" : storedTireStack.Season == TireSeason.Winter ? "❄️" : "🔄")</span>
@if (!string.IsNullOrEmpty(storedTireStack.TireSize))
{
<span class="tire-size-compact">@storedTireStack.TireSize</span>
}
</span> </span>
</div> </div>
} }
@@ -231,7 +256,7 @@ else
<span class="customer">@storedTireStack.Customer.FullName</span> <span class="customer">@storedTireStack.Customer.FullName</span>
@if (storedTireStack.Vehicle != null) @if (storedTireStack.Vehicle != null)
{ {
<span class="vehicle-info">@storedTireStack.Vehicle.ShortName</span> <span class="vehicle-info">🚗 @storedTireStack.Vehicle.ShortName</span>
} }
} }
else 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 // Batch creation
private AreaType batchArea = AreaType.Lager; private AreaType batchArea = AreaType.Lager;
private int batchRow = 1; private int batchRow = 1;
@@ -493,6 +493,33 @@
margin-bottom: 0; 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 { .row-chevron {
font-size: 0.6rem; font-size: 0.6rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -629,10 +656,48 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.card-content .tire-info { .card-content .vehicle-line {
display: block; display: flex;
font-size: var(--text-sm); align-items: center;
gap: 4px;
margin-top: 2px; 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 { .card-content.empty {
@@ -32,6 +32,11 @@
<div class="qr-status">QR-Code des Reports vor die Kamera halten</div> <div class="qr-status">QR-Code des Reports vor die Kamera halten</div>
} }
<div class="qr-manual">
<label for="@_fileInputId" class="btn btn-secondary w-100">📷 Foto aufnehmen / Bild wählen</label>
<input id="@_fileInputId" type="file" accept="image/*" capture="environment" style="display:none" @onchange="OnFileSelected" />
</div>
<div class="qr-manual"> <div class="qr-manual">
<label for="qr-manual-input">Oder Link / Nummer einfügen</label> <label for="qr-manual-input">Oder Link / Nummer einfügen</label>
<div class="qr-manual-row"> <div class="qr-manual-row">
@@ -53,6 +58,7 @@
[Parameter] public EventCallback<string> OnScanned { get; set; } [Parameter] public EventCallback<string> OnScanned { get; set; }
private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}"; private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}";
private readonly string _fileInputId = $"qr-file-{Guid.NewGuid():N}";
private DotNetObjectReference<QrScannerModal>? _selfRef; private DotNetObjectReference<QrScannerModal>? _selfRef;
private bool _scannerActive; private bool _scannerActive;
private bool _wasOpen; private bool _wasOpen;
@@ -127,6 +133,26 @@
await OnScanned.InvokeAsync(text.Trim()); await OnScanned.InvokeAsync(text.Trim());
} }
private async Task OnFileSelected(ChangeEventArgs e)
{
cameraError = null;
_scannerActive = false;
try
{
var text = await JS.InvokeAsync<string?>("qrScanner.scanFile", _elementId, _fileInputId);
if (!string.IsNullOrWhiteSpace(text))
{
await OnQrScanned(text);
return;
}
}
catch (JSException ex)
{
cameraError = ExtractMessage(ex.Message);
}
StateHasChanged();
}
private async Task SubmitManual() private async Task SubmitManual()
{ {
if (string.IsNullOrWhiteSpace(manualText)) return; if (string.IsNullOrWhiteSpace(manualText)) return;
+1
View File
@@ -12,6 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="QRCoder" Version="1.7.0" /> <PackageReference Include="QRCoder" Version="1.7.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+5
View File
@@ -128,6 +128,7 @@ builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<ILocationCodeService, LocationCodeService>(); builder.Services.AddScoped<ILocationCodeService, LocationCodeService>();
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>(); builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
builder.Services.AddScoped<ITireStackService, TireStackService>(); builder.Services.AddScoped<ITireStackService, TireStackService>();
builder.Services.AddScoped<ICustomerMergeService, CustomerMergeService>();
// Launch TTS01 tire report (QR-Code) lookup // Launch TTS01 tire report (QR-Code) lookup
builder.Services.AddHttpClient("LaunchReport", client => client.Timeout = TimeSpan.FromSeconds(15)); builder.Services.AddHttpClient("LaunchReport", client => client.Timeout = TimeSpan.FromSeconds(15));
@@ -141,6 +142,10 @@ builder.Services.AddHttpClient("LexwareApi");
builder.Services.AddScoped<ILexwareSyncService, LexwareSyncService>(); builder.Services.AddScoped<ILexwareSyncService, LexwareSyncService>();
builder.Services.AddHostedService<LexwareSyncBackgroundService>(); builder.Services.AddHostedService<LexwareSyncBackgroundService>();
// PDF export (QuestPDF Community license)
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;
builder.Services.AddSingleton<IStorageRowPdfService, StorageRowPdfService>();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline // Configure the HTTP request pipeline
@@ -0,0 +1,161 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Result of merging a local customer into a Lexware customer.
/// </summary>
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
{
/// <summary>
/// Local customers are created by this app and use the "K-" number prefix.
/// Lexware customers carry the numeric Lexware customer number.
/// </summary>
bool IsLocalCustomer(Customer customer);
/// <summary>
/// Moves all vehicles, tire stacks and task links from a locally created customer
/// to an existing Lexware customer and permanently deletes the local customer.
/// </summary>
Task<CustomerMergeResult> MergeAsync(Guid sourceId, Guid targetId);
}
public class CustomerMergeService : ICustomerMergeService
{
private readonly AppDbContext _context;
private readonly ILogger<CustomerMergeService> _logger;
public CustomerMergeService(AppDbContext context, ILogger<CustomerMergeService> 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<CustomerMergeResult> 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;
});
}
}
+4 -27
View File
@@ -16,7 +16,6 @@ public class LexwareSyncResult
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
public int CustomersCreated { get; set; } public int CustomersCreated { get; set; }
public int CustomersUpdated { get; set; } public int CustomersUpdated { get; set; }
public int CustomersDeactivated { get; set; }
public int TotalProcessed { 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.Email != email) { existing.Email = email; changed = true; }
if (existing.Notes != notes) { existing.Notes = notes; 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) if (changed)
{ {
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
@@ -357,36 +353,17 @@ public class LexwareSyncService : ILexwareSyncService
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Deactivate customers not found in Lexware (Lexware is single source of truth) // Note: customers not present in Lexware are intentionally left untouched.
var lexwareCustomerNumbers = allContacts // The local IsActive flag is managed exclusively within this application.
.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 // Update sync timestamp
settings.LastSyncAt = DateTime.UtcNow; settings.LastSyncAt = DateTime.UtcNow;
settings.LastSyncCount = result.TotalProcessed; 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); await _siteSettingsService.SaveLexwareApiSettingsAsync(settings);
result.Success = true; 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); _logger.LogInformation("Lexware sync completed: {Message}", result.Message);
} }
@@ -0,0 +1,184 @@
using Lagerbestand.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace Lagerbestand.Services;
public interface IStorageRowPdfService
{
/// <summary>
/// Generates a printable A4 inventory sheet for one shelf row (Area + Row).
/// </summary>
byte[] GenerateRowPdf(AreaType area, int row, IReadOnlyList<StorageLocation> 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<StorageLocation> 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<StorageLocation> 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));
}
+3 -3
View File
@@ -577,8 +577,8 @@ public class TireStackService : ITireStackService
.Select(g => new { Season = g.Key, Count = g.Count() }) .Select(g => new { Season = g.Key, Count = g.Count() })
.ToListAsync(); .ToListAsync();
var totalLocations = await _context.StorageLocations var availableLocations = await _context.StorageLocations
.Where(s => s.IsActive) .Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied)
.CountAsync(); .CountAsync();
var occupiedLocations = await _context.TireStacks var occupiedLocations = await _context.TireStacks
@@ -598,7 +598,7 @@ public class TireStackService : ITireStackService
TotalStacksStored = storedStacks.Sum(x => x.Count), TotalStacksStored = storedStacks.Sum(x => x.Count),
TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp), TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp),
TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive), TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive),
AvailableLocations = totalLocations - occupiedLocations, AvailableLocations = availableLocations,
OccupiedLocations = occupiedLocations, OccupiedLocations = occupiedLocations,
SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0, SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0,
WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0, WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0,
+25
View File
@@ -49,6 +49,31 @@
} catch (e) { /* ignore */ } } 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 // beforeunload: warn user when draft flag is set
// --------------------------------------------------------- // ---------------------------------------------------------
+59 -16
View File
@@ -9,33 +9,43 @@ window.qrScanner = (function () {
if (window.Html5Qrcode) return; if (window.Html5Qrcode) return;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const s = document.createElement('script'); 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.onload = resolve;
s.onerror = () => reject(new Error('QR-Bibliothek konnte nicht geladen werden.')); s.onerror = () => reject(new Error('QR-Bibliothek konnte nicht geladen werden.'));
document.head.appendChild(s); 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) { async function start(elementId, dotNetRef) {
await stop(); await stop();
await ensureLib(); await ensureLib();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!window.isSecureContext || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Kamera wird von diesem Browser nicht unterstützt (HTTPS erforderlich).'); throw new Error('Live-Kamera nicht verfügbar (HTTPS erforderlich). Bitte „Foto aufnehmen“ verwenden.');
} }
scanner = new Html5Qrcode(elementId, { scanner = createScanner(elementId);
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
verbose: false
});
const config = { const config = {
fps: 10, fps: 10,
qrbox: (w, h) => { qrbox: (w, h) => {
const size = Math.max(180, Math.min(w, h) * 0.7); const size = Math.floor(Math.max(150, Math.min(w, h) * 0.7));
return { width: size, height: size }; return { width: Math.min(size, w), height: Math.min(size, h) };
}, },
aspectRatio: 1.0 disableFlip: false
}; };
const onSuccess = (text) => { const onSuccess = (text) => {
@@ -46,13 +56,46 @@ window.qrScanner = (function () {
dotNetRef.invokeMethodAsync('OnQrScanned', text); 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 { try {
await scanner.start({ facingMode: 'environment' }, config, onSuccess, () => { }); return await fileScanner.scanFile(file, false);
} catch (e) { } catch (e) {
// Fallback: any camera throw new Error('Kein QR-Code im Bild erkannt.');
const cams = await Html5Qrcode.getCameras(); } finally {
if (!cams || cams.length === 0) throw new Error('Keine Kamera gefunden.'); try { fileScanner.clear(); } catch { }
await scanner.start(cams[0].id, config, onSuccess, () => { }); input.value = '';
} }
} }
@@ -84,5 +127,5 @@ window.qrScanner = (function () {
} catch (e) { /* audio is best-effort */ } } catch (e) { /* audio is best-effort */ }
} }
return { start, stop, playSound }; return { start, stop, scanFile, playSound };
})(); })();