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 ITireStackService TireStackService
@inject ILexwareSyncService LexwareSyncService
@inject ICustomerMergeService CustomerMergeService
@inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession
@inject IJSRuntime JS
@@ -187,6 +188,12 @@ else
<button class="btn btn-sm btn-outline" @onclick="() => EditCustomer(customer)">
Bearbeiten
</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">
Reifenstapel einlagern
</a>
@@ -329,6 +336,88 @@ else
</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 {
@@ -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<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()
{
if (UserSession.IsAdmin)
@@ -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) */
@@ -4,6 +4,8 @@
@inject ILocationCodeService LocationCodeService
@inject ITireStackService TireStackService
@inject UserSessionService UserSession
@inject IStorageRowPdfService RowPdfService
@inject IJSRuntime JS
<PageTitle>Lagerplätze - Reifen Späth Lagerverwaltung</PageTitle>
@@ -52,7 +54,7 @@ else
</button>
<button class="stat-chip available @(statusFilter == StatusAvailable ? "active" : "")"
@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 class="stat-chip occupied @(statusFilter == StatusOccupied ? "active" : "")"
@onclick="FilterByStatusOccupied" title="Nur belegte Plätze anzeigen">
@@ -132,11 +134,24 @@ else
var occupiedCount = rowGroup.Count(l => l.IsOccupied);
var totalCount = rowGroup.Count();
<div class="row-section @(isCollapsed ? "collapsed" : "")">
<button type="button" class="row-header" @onclick="() => ToggleRow(rowKey)">
<span class="row-chevron">@(isCollapsed ? "▶" : "▼")</span>
<h3 class="row-title">Reihe @rowGroup.Key</h3>
<span class="row-summary">@occupiedCount / @totalCount belegt</span>
</button>
<div class="row-header-wrap">
<button type="button" class="row-header" @onclick="() => ToggleRow(rowKey)">
<span class="row-chevron">@(isCollapsed ? "▶" : "▼")</span>
<h3 class="row-title">Reihe @rowGroup.Key</h3>
<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)
{
@foreach (var posGroup in rowGroup.GroupBy(l => l.Position).OrderBy(g => (int)g.Key))
@@ -155,11 +170,21 @@ else
@if (location.IsOccupied && storedTireStack != null)
{
<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")
</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">
@(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>
</div>
}
@@ -231,7 +256,7 @@ else
<span class="customer">@storedTireStack.Customer.FullName</span>
@if (storedTireStack.Vehicle != null)
{
<span class="vehicle-info">@storedTireStack.Vehicle.ShortName</span>
<span class="vehicle-info">🚗 @storedTireStack.Vehicle.ShortName</span>
}
}
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;
@@ -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 {