Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2aef67099 | ||
|
|
7a0406eaa2 | ||
|
|
766703524a |
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
|
||||
@@ -175,6 +176,8 @@ else
|
||||
</span>
|
||||
<span class="info">@ts.TireSize (@TireStack.TiresPerStack Reifen)</span>
|
||||
<span class="location"><span aria-hidden="true">📍</span> @(ts.StorageLocation?.Code ?? "-")</span>
|
||||
<TireInspectionPanel TireStackId="@ts.Id" AllowEdit="@UserSession.IsWorkerOrAdmin" Compact="true"
|
||||
ScannerSubtitle="@($"Kunde: {customer.FullName}")" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -185,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>
|
||||
@@ -327,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 {
|
||||
@@ -360,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)
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
.mini-tireset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
@@ -521,6 +522,11 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mini-tireset ::deep .insp-panel {
|
||||
flex-basis: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.mini-tireset {
|
||||
gap: 10px;
|
||||
@@ -735,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) */
|
||||
@@ -590,6 +590,8 @@ else
|
||||
<div class="tirestack-info">
|
||||
<span class="vehicle">@(ts.TireStack.Vehicle?.ShortName ?? "Ohne KFZ")</span>
|
||||
<span class="details">@(ts.TireStack.TireSize ?? "?") | @(ts.TireStack.StorageLocation?.Code ?? "-")</span>
|
||||
<TireInspectionPanel TireStackId="@ts.TireStack.Id" AllowEdit="true" Compact="true"
|
||||
ScannerSubtitle="@ts.TireStack.TicketNumber" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -949,6 +949,12 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (createdTireStack != null && !isInventoryMode)
|
||||
{
|
||||
<TireInspectionPanel TireStackId="@createdTireStack.Id" AllowEdit="true" InitiallyOpen="@(pendingReportSaved)"
|
||||
ScannerSubtitle="@(selectedCustomer != null ? $"Kunde: {selectedCustomer.FullName}" : null)" />
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button class="btn btn-outline" @onclick="ResetForm">
|
||||
@(isInventoryMode ? "Weiteren Stapel einlagern" : "Neuen Reifenstapel einlagern")
|
||||
@@ -1185,6 +1191,7 @@ else
|
||||
private bool isLoadingReport;
|
||||
private TireReportData? scannedReport;
|
||||
private TireReportData? pendingReport;
|
||||
private bool pendingReportSaved;
|
||||
private ReportAssignMode reportMode = ReportAssignMode.New;
|
||||
private List<TireStack> customerStacksForReport = new();
|
||||
private Guid? reportTargetStackId;
|
||||
@@ -1999,12 +2006,14 @@ else
|
||||
UserSession.CurrentUser?.Id);
|
||||
|
||||
createdTireStack = created;
|
||||
pendingReportSaved = false;
|
||||
|
||||
if (pendingReport != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TireStackService.AddInspectionAsync(pendingReport.ToInspection(created.Id, UserSession.CurrentUser?.Id));
|
||||
pendingReportSaved = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@inject ICustomerService CustomerService
|
||||
@inject ITireStackService TireStackService
|
||||
@inject IStorageLocationService StorageLocationService
|
||||
@inject ITireReportService TireReportService
|
||||
@inject UserSessionService UserSession
|
||||
|
||||
<PageTitle>Reifenstapel Abholen - Reifen Späth Lagerverwaltung</PageTitle>
|
||||
@@ -151,6 +152,8 @@ else
|
||||
<span class="stored-date">Eingelagert: @ts.StoredAt.ToLocalTime().ToString("dd.MM.yyyy")</span>
|
||||
<span class="days-stored">(@ts.DaysStored Tage)</span>
|
||||
</div>
|
||||
<TireInspectionPanel TireStackId="@ts.Id" AllowEdit="true" Compact="true"
|
||||
ScannerSubtitle="@(ts.Customer != null ? $"Kunde: {ts.Customer.FullName}" : ts.TicketNumber)" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -204,12 +207,47 @@ else
|
||||
}
|
||||
</dd></div>
|
||||
</dl>
|
||||
<TireInspectionPanel TireStackId="@swapOutgoing.Id" AllowEdit="true" Compact="true"
|
||||
ScannerSubtitle="@(swapOutgoing.Customer != null ? $"Kunde: {swapOutgoing.Customer.FullName}" : swapOutgoing.TicketNumber)" />
|
||||
</div>
|
||||
|
||||
@* Incoming *@
|
||||
<div class="swap-box incoming">
|
||||
<h3 class="swap-box-title"><span aria-hidden="true">⬇️</span> Einlagerung (kommt vom Fahrzeug)</h3>
|
||||
|
||||
@* QR report scan for the incoming set *@
|
||||
<div class="report-scan-bar">
|
||||
@if (swapReport == null)
|
||||
{
|
||||
<div class="report-scan-text">
|
||||
<span class="report-scan-icon" aria-hidden="true">📷</span>
|
||||
<span>Profiltiefen-Report (Launch TTS01) per QR-Code übernehmen</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline" @onclick="OpenQrScanner" disabled="@(isLoadingReport || isProcessing)">
|
||||
@if (isLoadingReport)
|
||||
{
|
||||
<span class="spinner"></span>
|
||||
<span>Report wird geladen…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>QR-Code scannen</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="report-attached">
|
||||
<div class="report-attached-head">
|
||||
<span class="report-scan-icon" aria-hidden="true">📋</span>
|
||||
<strong>Report wird mit der Einlagerung gespeichert</strong>
|
||||
<button type="button" class="btn-link" @onclick="RemoveSwapReport">Entfernen</button>
|
||||
</div>
|
||||
@ReportSummaryFragment(swapReport)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="swap-tabs" role="tablist">
|
||||
<button type="button" class="swap-tab @(swapUseHistory ? "active" : "")" role="tab" aria-selected="@swapUseHistory"
|
||||
@onclick="() => swapUseHistory = true" disabled="@(swapHistory.Count == 0)">
|
||||
@@ -402,6 +440,91 @@ else
|
||||
HighlightLocationId="@swapOutgoing.StorageLocationId"
|
||||
Subtitle="@($"Einlagerung für {swapOutgoing.Customer?.FullName} – {SeasonLabel(swapUseHistory ? (swapSelectedHistory?.Season ?? swapNew.Season) : swapNew.Season)}")"
|
||||
OnSelected="OnTargetLocationSelected" />
|
||||
|
||||
<QrScannerModal @bind-IsOpen="showQrScanner"
|
||||
Title="Reifen-Report scannen"
|
||||
Subtitle="@(swapOutgoing.Customer != null ? $"Kunde: {swapOutgoing.Customer.FullName}" : null)"
|
||||
OnScanned="OnQrScanned" />
|
||||
}
|
||||
|
||||
@* Report preview modal (swap) *@
|
||||
@if (scannedReport != null)
|
||||
{
|
||||
<div class="report-overlay" @onclick="CloseReportModal">
|
||||
<div class="report-dialog" role="dialog" aria-modal="true" aria-labelledby="report-title" @onclick:stopPropagation="true">
|
||||
<div class="report-header">
|
||||
<div>
|
||||
<h3 id="report-title">📋 Reifen-Report</h3>
|
||||
<p class="report-subtitle">
|
||||
Nr. <span class="mono">@scannedReport.RecordSn</span>
|
||||
@if (scannedReport.MeasuredAt.HasValue)
|
||||
{
|
||||
<text> · gemessen am @scannedReport.MeasuredAt.Value.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</text>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="report-close" @onclick="CloseReportModal" aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="report-body">
|
||||
@if (!scannedReport.HasTreadData)
|
||||
{
|
||||
<div class="report-warning">
|
||||
⚠️ Der Report enthält keine Profiltiefen-Werte. Werte können unten manuell ergänzt werden.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="report-meta">
|
||||
<div class="form-group">
|
||||
<label>Kennzeichen</label>
|
||||
<input type="text" class="form-control" @bind="scannedReport.LicensePlate" placeholder="z.B. ND-XX 123" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kilometerstand</label>
|
||||
<input type="number" class="form-control" @bind="scannedReport.Mileage" min="0" placeholder="km" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tread-grid" role="group" aria-label="Profiltiefen">
|
||||
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthFL)">
|
||||
<label>Vorne links</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthFL" /><span>mm</span></div>
|
||||
</div>
|
||||
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthFR)">
|
||||
<label>Vorne rechts</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthFR" /><span>mm</span></div>
|
||||
</div>
|
||||
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthRL)">
|
||||
<label>Hinten links</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthRL" /><span>mm</span></div>
|
||||
</div>
|
||||
<div class="tread-cell rating-@RatingClass(scannedReport, scannedReport.TreadDepthRR)">
|
||||
<label>Hinten rechts</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="scannedReport.TreadDepthRR" /><span>mm</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(scannedReport.Recommendation))
|
||||
{
|
||||
<div class="report-recommendation">💡 @scannedReport.Recommendation</div>
|
||||
}
|
||||
|
||||
<a class="report-link" href="@scannedReport.ReportUrl" target="_blank" rel="noopener">Original-Report öffnen ↗</a>
|
||||
|
||||
<div class="report-warning info">
|
||||
Die Messwerte werden dem Satz zugeordnet, der beim Reifenwechsel <strong>eingelagert</strong> wird
|
||||
(@(swapUseHistory ? (swapSelectedHistory?.TicketNumber ?? "Satz aus Historie") : "neu erfasster Satz")).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-footer">
|
||||
<button type="button" class="btn btn-outline" @onclick="CloseReportModal">Verwerfen</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="ApplySwapReport">
|
||||
<span>Für Einlagerung übernehmen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Action Chooser Modal *@
|
||||
@@ -457,6 +580,14 @@ else
|
||||
{
|
||||
<p class="success-detail">Lagerplatz <strong>@swapResult.FreedLocationCode</strong> ist wieder frei.</p>
|
||||
}
|
||||
@if (swapReportSaved)
|
||||
{
|
||||
<p class="success-detail">📋 Profiltiefen-Report wurde am eingelagerten Satz gespeichert.</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(swapReportError))
|
||||
{
|
||||
<p class="success-detail warn">⚠️ @swapReportError</p>
|
||||
}
|
||||
<button class="btn btn-primary" @onclick="CloseSwapSuccess">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,6 +667,15 @@ else
|
||||
private Guid? swapTargetLocationId => swapTargetLocation?.Id;
|
||||
private SwapResult? swapResult;
|
||||
|
||||
// QR report for the incoming (stored) set
|
||||
private bool showQrScanner;
|
||||
private bool isLoadingReport;
|
||||
private TireReportData? scannedReport;
|
||||
private TireReportData? swapReport;
|
||||
private bool swapReportSaved;
|
||||
private string? swapReportError;
|
||||
private const int NotesMaxLength = 1000;
|
||||
|
||||
private bool CanSwap => swapOutgoing != null && (swapUseHistory ? swapSelectedHistory != null : !string.IsNullOrWhiteSpace(swapNew.TireSize));
|
||||
|
||||
[SupplyParameterFromQuery(Name = "stackId")]
|
||||
@@ -563,6 +703,111 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private static string RatingClass(TireMeasureRating r) => r switch
|
||||
{
|
||||
TireMeasureRating.Ok => "ok",
|
||||
TireMeasureRating.Warning => "warn",
|
||||
TireMeasureRating.Critical => "crit",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static string RatingClass(TireReportData r, decimal? mm) => RatingClass(TireInspection.RateDepth(mm, r.CriticalThreshold, r.WarningThreshold));
|
||||
|
||||
private static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
|
||||
|
||||
private RenderFragment<TireReportData> ReportSummaryFragment => report => __builder =>
|
||||
{
|
||||
<div class="report-summary">
|
||||
@if (!string.IsNullOrEmpty(report.LicensePlate)) { <span class="report-chip">🚗 @report.LicensePlate</span> }
|
||||
@if (report.Mileage.HasValue) { <span class="report-chip">@report.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km</span> }
|
||||
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFL)">VL @Fmt(report.TreadDepthFL)</span>
|
||||
<span class="report-chip rating-@RatingClass(report, report.TreadDepthFR)">VR @Fmt(report.TreadDepthFR)</span>
|
||||
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRL)">HL @Fmt(report.TreadDepthRL)</span>
|
||||
<span class="report-chip rating-@RatingClass(report, report.TreadDepthRR)">HR @Fmt(report.TreadDepthRR)</span>
|
||||
</div>
|
||||
};
|
||||
|
||||
private void OpenQrScanner()
|
||||
{
|
||||
errorMessage = null;
|
||||
showQrScanner = true;
|
||||
}
|
||||
|
||||
private async Task OnQrScanned(string text)
|
||||
{
|
||||
showQrScanner = false;
|
||||
|
||||
if (swapOutgoing == null) return;
|
||||
|
||||
if (!TireReportService.TryParseReportUrl(text, out var recordSn, out _))
|
||||
{
|
||||
errorMessage = "Der QR-Code enthält keinen gültigen Reifen-Report-Link.";
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingReport = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
scannedReport = await TireReportService.FetchAsync(recordSn);
|
||||
}
|
||||
catch (TireReportException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Report konnte nicht verarbeitet werden: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoadingReport = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseReportModal() => scannedReport = null;
|
||||
|
||||
private void ApplySwapReport()
|
||||
{
|
||||
if (scannedReport == null) return;
|
||||
|
||||
scannedReport.RecomputeRatings();
|
||||
swapReport = scannedReport;
|
||||
scannedReport = null;
|
||||
|
||||
// Prefill the manually captured set with the measured data
|
||||
if (!swapUseHistory)
|
||||
{
|
||||
var de = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
|
||||
var lines = new List<string>();
|
||||
if (!string.IsNullOrEmpty(swapReport.LicensePlate)) lines.Add($"Kennzeichen: {swapReport.LicensePlate}");
|
||||
if (swapReport.Mileage.HasValue) lines.Add($"km-Stand: {swapReport.Mileage.Value.ToString("N0", de)}");
|
||||
if (swapReport.HasTreadData)
|
||||
lines.Add($"Profil VL {Fmt(swapReport.TreadDepthFL)} / VR {Fmt(swapReport.TreadDepthFR)} / HL {Fmt(swapReport.TreadDepthRL)} / HR {Fmt(swapReport.TreadDepthRR)}");
|
||||
if (!string.IsNullOrEmpty(swapReport.Recommendation)) lines.Add(swapReport.Recommendation);
|
||||
|
||||
var block = string.Join("\n", lines);
|
||||
if (!string.IsNullOrWhiteSpace(block))
|
||||
{
|
||||
var merged = string.IsNullOrWhiteSpace(swapNew.Notes) ? block : swapNew.Notes.TrimEnd() + "\n" + block;
|
||||
swapNew.Notes = merged.Length <= NotesMaxLength ? merged : merged[..NotesMaxLength];
|
||||
}
|
||||
|
||||
if (swapReport.HasTreadData)
|
||||
{
|
||||
var minDepth = new[] { swapReport.TreadDepthFL, swapReport.TreadDepthFR, swapReport.TreadDepthRL, swapReport.TreadDepthRR }
|
||||
.Where(v => v.HasValue).Select(v => v!.Value).Min();
|
||||
swapNew.Condition = minDepth < TireInspection.DefaultWarningThreshold ? TireCondition.Poor
|
||||
: minDepth < 5m ? TireCondition.Fair
|
||||
: TireCondition.Good;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveSwapReport() => swapReport = null;
|
||||
|
||||
private static string SeasonIcon(TireSeason s) => s == TireSeason.Sommer ? "☀️" : s == TireSeason.Winter ? "❄️" : "🔄";
|
||||
private static string SeasonLabel(TireSeason s) => s == TireSeason.Sommer ? "☀️ Sommer" : s == TireSeason.Winter ? "❄️ Winter" : "🔄 Allrad";
|
||||
|
||||
@@ -609,6 +854,11 @@ else
|
||||
showLocationPicker = false;
|
||||
swapTargetLocation = outgoing.StorageLocation;
|
||||
swapSelectedHistory = null;
|
||||
swapReport = null;
|
||||
scannedReport = null;
|
||||
showQrScanner = false;
|
||||
swapReportSaved = false;
|
||||
swapReportError = null;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
@@ -669,6 +919,9 @@ else
|
||||
swapHistory.Clear();
|
||||
swapSelectedHistory = null;
|
||||
showLocationPicker = false;
|
||||
showQrScanner = false;
|
||||
scannedReport = null;
|
||||
swapReport = null;
|
||||
swapTargetLocation = null;
|
||||
}
|
||||
|
||||
@@ -694,13 +947,31 @@ else
|
||||
: swapNew;
|
||||
|
||||
var targetLocation = swapTargetLocationId == swapOutgoing.StorageLocationId ? null : swapTargetLocationId;
|
||||
var report = swapReport;
|
||||
|
||||
swapResult = await TireStackService.SwapAsync(
|
||||
var result = await TireStackService.SwapAsync(
|
||||
swapOutgoing.Id,
|
||||
incoming,
|
||||
targetLocation,
|
||||
UserSession.CurrentUser?.Id);
|
||||
|
||||
swapReportSaved = false;
|
||||
swapReportError = null;
|
||||
|
||||
if (report != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TireStackService.AddInspectionAsync(report.ToInspection(result.Incoming.Id, UserSession.CurrentUser?.Id));
|
||||
swapReportSaved = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
swapReportError = $"Reifenwechsel erfolgreich, aber der Report konnte nicht gespeichert werden: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
swapResult = result;
|
||||
CancelSwap();
|
||||
searchResults.Clear();
|
||||
searchTerm = "";
|
||||
|
||||
@@ -1102,4 +1102,276 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
QR Report (swap flow)
|
||||
======================================== */
|
||||
.report-scan-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 0.5px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.report-scan-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.report-scan-icon {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.report-scan-bar .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.report-attached {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.report-attached-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #60a5fa;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.report-chip {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.report-chip.rating-ok { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.4); color: #86efac; }
|
||||
.report-chip.rating-warn { background: rgba(250, 204, 21, 0.15); border-color: rgba(250, 204, 21, 0.4); color: #fde047; }
|
||||
.report-chip.rating-crit { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
|
||||
|
||||
.report-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 1150;
|
||||
}
|
||||
|
||||
.report-dialog {
|
||||
background: #1c1c1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.report-header h3 {
|
||||
margin: 0;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.report-subtitle {
|
||||
margin: 0.2rem 0 0;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.report-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.report-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.report-body {
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.report-warning {
|
||||
background: rgba(250, 204, 21, 0.1);
|
||||
border: 0.5px solid rgba(250, 204, 21, 0.35);
|
||||
color: #fde047;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.report-warning.info {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
border-color: rgba(96, 165, 250, 0.3);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-meta .form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tread-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tread-cell {
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.tread-cell.rating-ok { border-color: rgba(34, 197, 94, 0.5); background: rgba(34, 197, 94, 0.08); }
|
||||
.tread-cell.rating-warn { border-color: rgba(250, 204, 21, 0.5); background: rgba(250, 204, 21, 0.08); }
|
||||
.tread-cell.rating-crit { border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.08); }
|
||||
|
||||
.tread-cell label {
|
||||
display: block;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tread-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tread-input .form-control {
|
||||
max-width: 110px;
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.tread-input span {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.report-recommendation {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
border: 0.5px solid rgba(96, 165, 250, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.report-link {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: #60a5fa;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.report-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.success-detail.warn {
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.report-meta,
|
||||
.tread-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: inherited from app.css (section 17) */
|
||||
@@ -399,6 +399,8 @@ else
|
||||
<textarea id="edit-notes" class="form-control" rows="3" @bind="editStack.Notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<TireInspectionPanel TireStackId="@editStack.Id" AllowEdit="true" Compact="true"
|
||||
ScannerSubtitle="@editStack.TicketNumber" />
|
||||
@if (!string.IsNullOrEmpty(editError))
|
||||
{
|
||||
<p class="edit-error" role="alert">@editError</p>
|
||||
@@ -536,32 +538,11 @@ else
|
||||
<dd><time datetime="@stack.ExpectedPickupDate.Value.ToString("yyyy-MM-dd")">@stack.ExpectedPickupDate.Value.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
|
||||
</div>
|
||||
}
|
||||
@if (latestInspections.TryGetValue(stack.Id, out var insp) && insp != null)
|
||||
{
|
||||
<div class="detail-row">
|
||||
<dt>Letzte Messung</dt>
|
||||
<dd>
|
||||
<div class="tread-chips">
|
||||
<span class="tread-chip rating-@RatingClass(insp.RatingFL)">VL @FmtMm(insp.TreadDepthFL)</span>
|
||||
<span class="tread-chip rating-@RatingClass(insp.RatingFR)">VR @FmtMm(insp.TreadDepthFR)</span>
|
||||
<span class="tread-chip rating-@RatingClass(insp.RatingRL)">HL @FmtMm(insp.TreadDepthRL)</span>
|
||||
<span class="tread-chip rating-@RatingClass(insp.RatingRR)">HR @FmtMm(insp.TreadDepthRR)</span>
|
||||
</div>
|
||||
<span class="muted">
|
||||
@((insp.MeasuredAt ?? insp.CreatedAt).ToLocalTime().ToString("dd.MM.yyyy"))
|
||||
@if (insp.Mileage.HasValue) { <text> · @insp.Mileage.Value.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) km</text> }
|
||||
@if (!string.IsNullOrEmpty(insp.LicensePlate)) { <text> · @insp.LicensePlate</text> }
|
||||
</span>
|
||||
@if (!string.IsNullOrEmpty(insp.ReportUrl))
|
||||
{
|
||||
<a class="report-link" href="@insp.ReportUrl" target="_blank" rel="noopener">Report öffnen ↗</a>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<TireInspectionPanel TireStackId="@stack.Id" AllowEdit="@UserSession.IsWorkerOrAdmin" Compact="true"
|
||||
ScannerSubtitle="@(stack.Customer != null ? $"Kunde: {stack.Customer.FullName}" : stack.TicketNumber)" />
|
||||
};
|
||||
|
||||
private List<TireStack> allTireStacks = new();
|
||||
@@ -575,30 +556,6 @@ else
|
||||
private bool? typeFilter; // null = all, false = customer, true = inventory
|
||||
private string selectedCategoryId = "";
|
||||
private TireStack? selectedStack;
|
||||
private readonly Dictionary<Guid, TireInspection?> latestInspections = new();
|
||||
|
||||
private static string RatingClass(TireMeasureRating r) => r switch
|
||||
{
|
||||
TireMeasureRating.Ok => "ok",
|
||||
TireMeasureRating.Warning => "warn",
|
||||
TireMeasureRating.Critical => "crit",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static string FmtMm(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " mm" : "–";
|
||||
|
||||
private async Task LoadLatestInspection(TireStack ts)
|
||||
{
|
||||
if (latestInspections.ContainsKey(ts.Id)) return;
|
||||
try
|
||||
{
|
||||
latestInspections[ts.Id] = await TireStackService.GetLatestInspectionAsync(ts.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
latestInspections[ts.Id] = null;
|
||||
}
|
||||
}
|
||||
private TireStack? editStack;
|
||||
private TireStack? editSource;
|
||||
private bool showEditModal;
|
||||
@@ -744,13 +701,9 @@ else
|
||||
filteredTireStacks = query.OrderBy(ts => ts.StorageLocation?.Code).ToList();
|
||||
}
|
||||
|
||||
private async Task SelectStack(TireStack ts)
|
||||
private void SelectStack(TireStack ts)
|
||||
{
|
||||
selectedStack = selectedStack?.Id == ts.Id ? null : ts;
|
||||
if (selectedStack != null)
|
||||
{
|
||||
await LoadLatestInspection(selectedStack);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenEditModal()
|
||||
@@ -831,14 +784,10 @@ else
|
||||
return locationLookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
|
||||
}
|
||||
|
||||
private async Task SelectLocation(StorageLocation? location)
|
||||
private void SelectLocation(StorageLocation? location)
|
||||
{
|
||||
if (location == null) return;
|
||||
selectedLocation = selectedLocation?.Id == location.Id ? null : location;
|
||||
if (selectedLocation?.OccupiedTireStack != null)
|
||||
{
|
||||
await LoadLatestInspection(selectedLocation.OccupiedTireStack);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCellTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot)
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
<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">
|
||||
<label for="qr-manual-input">Oder Link / Nummer einfügen</label>
|
||||
<div class="qr-manual-row">
|
||||
@@ -53,6 +58,7 @@
|
||||
[Parameter] public EventCallback<string> OnScanned { get; set; }
|
||||
|
||||
private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}";
|
||||
private readonly string _fileInputId = $"qr-file-{Guid.NewGuid():N}";
|
||||
private DotNetObjectReference<QrScannerModal>? _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<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()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manualText)) return;
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
@inject ITireStackService TireStackService
|
||||
@inject ITireReportService TireReportService
|
||||
@inject UserSessionService UserSession
|
||||
|
||||
<section class="insp-panel @(Compact ? "compact" : "")" aria-label="Profiltiefen-Messungen">
|
||||
<button type="button" class="insp-toggle" @onclick="Toggle" aria-expanded="@isOpen" aria-controls="@panelId">
|
||||
<span class="insp-toggle-icon" aria-hidden="true">📋</span>
|
||||
<span class="insp-toggle-title">Profiltiefen / Reifen-Report</span>
|
||||
@if (latest != null)
|
||||
{
|
||||
<span class="insp-toggle-summary">
|
||||
<span class="insp-chip rating-@RatingClass(latest.RatingFL)">VL @Fmt(latest.TreadDepthFL)</span>
|
||||
<span class="insp-chip rating-@RatingClass(latest.RatingFR)">VR @Fmt(latest.TreadDepthFR)</span>
|
||||
<span class="insp-chip rating-@RatingClass(latest.RatingRL)">HL @Fmt(latest.TreadDepthRL)</span>
|
||||
<span class="insp-chip rating-@RatingClass(latest.RatingRR)">HR @Fmt(latest.TreadDepthRR)</span>
|
||||
</span>
|
||||
}
|
||||
else if (loaded)
|
||||
{
|
||||
<span class="insp-toggle-summary muted">Keine Messung</span>
|
||||
}
|
||||
<span class="insp-chevron @(isOpen ? "open" : "")" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
|
||||
@if (isOpen)
|
||||
{
|
||||
<div id="@panelId" class="insp-body">
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="insp-status"><span class="spinner"></span> Messungen werden geladen…</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="insp-error" role="alert">⚠️ @error</div>
|
||||
}
|
||||
|
||||
@if (AllowEdit)
|
||||
{
|
||||
<div class="insp-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline" @onclick="OpenScanner" disabled="@(isBusy || editing != null)">
|
||||
@if (isFetching) { <span class="spinner"></span><span>Report wird geladen…</span> }
|
||||
else { <span>📷 QR-Report scannen</span> }
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" @onclick="StartManual" disabled="@(isBusy || editing != null)">
|
||||
✎ Manuell erfassen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editing != null)
|
||||
{
|
||||
<div class="insp-edit">
|
||||
<h4>@(isNew ? "Neue Messung" : "Messung bearbeiten")</h4>
|
||||
<div class="insp-meta">
|
||||
<div class="form-group">
|
||||
<label>Datum</label>
|
||||
<input type="date" class="form-control" value="@(editing.MeasuredAt?.ToLocalTime().ToString("yyyy-MM-dd"))" @onchange="OnDateChanged" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kennzeichen</label>
|
||||
<input type="text" class="form-control" @bind="editing.LicensePlate" placeholder="z.B. ND-XX 123" maxlength="20" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kilometerstand</label>
|
||||
<input type="number" class="form-control" @bind="editing.Mileage" min="0" placeholder="km" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tread-grid" role="group" aria-label="Profiltiefen">
|
||||
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthFL))">
|
||||
<label>Vorne links</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthFL" /><span>mm</span></div>
|
||||
</div>
|
||||
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthFR))">
|
||||
<label>Vorne rechts</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthFR" /><span>mm</span></div>
|
||||
</div>
|
||||
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthRL))">
|
||||
<label>Hinten links</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthRL" /><span>mm</span></div>
|
||||
</div>
|
||||
<div class="tread-cell rating-@RatingClass(TireInspection.RateDepth(editing.TreadDepthRR))">
|
||||
<label>Hinten rechts</label>
|
||||
<div class="tread-input"><input type="number" step="0.1" min="0" max="20" class="form-control" @bind="editing.TreadDepthRR" /><span>mm</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Empfehlung / Bemerkung</label>
|
||||
<textarea class="form-control" rows="2" @bind="editing.Recommendation" maxlength="1000"></textarea>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(editing.ReportUrl))
|
||||
{
|
||||
<a class="insp-link" href="@editing.ReportUrl" target="_blank" rel="noopener">Original-Report öffnen ↗</a>
|
||||
}
|
||||
<div class="insp-edit-footer">
|
||||
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelEdit" disabled="@isBusy">Abbrechen</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveEdit" disabled="@isBusy">
|
||||
@if (isBusy) { <span class="spinner"></span> }
|
||||
<span>Speichern</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (inspections.Count == 0)
|
||||
{
|
||||
@if (editing == null)
|
||||
{
|
||||
<p class="insp-empty">Noch keine Profiltiefen-Messung für diesen Satz vorhanden.</p>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="insp-list" role="list">
|
||||
@foreach (var insp in inspections)
|
||||
{
|
||||
<li @key="insp.Id" class="insp-item @(insp == latest ? "latest" : "")">
|
||||
<div class="insp-item-head">
|
||||
<span class="insp-date">
|
||||
@((insp.MeasuredAt ?? insp.CreatedAt).ToLocalTime().ToString("dd.MM.yyyy HH:mm"))
|
||||
@if (insp == latest) { <span class="insp-badge">aktuell</span> }
|
||||
</span>
|
||||
<span class="insp-source muted">@insp.Source@(string.IsNullOrEmpty(insp.RecordSn) ? "" : $" · {insp.RecordSn}")</span>
|
||||
@if (AllowEdit)
|
||||
{
|
||||
<span class="insp-item-actions">
|
||||
<button type="button" class="btn-link" @onclick="() => StartEdit(insp)" disabled="@(isBusy || editing != null)">Bearbeiten</button>
|
||||
@if (confirmDeleteId == insp.Id)
|
||||
{
|
||||
<button type="button" class="btn-link danger" @onclick="() => Delete(insp)" disabled="@isBusy">Wirklich löschen</button>
|
||||
<button type="button" class="btn-link" @onclick="() => confirmDeleteId = null" disabled="@isBusy">Nein</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn-link danger" @onclick="() => confirmDeleteId = insp.Id" disabled="@(isBusy || editing != null)">Löschen</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="insp-chips">
|
||||
<span class="insp-chip rating-@RatingClass(insp.RatingFL)">VL @Fmt(insp.TreadDepthFL)</span>
|
||||
<span class="insp-chip rating-@RatingClass(insp.RatingFR)">VR @Fmt(insp.TreadDepthFR)</span>
|
||||
<span class="insp-chip rating-@RatingClass(insp.RatingRL)">HL @Fmt(insp.TreadDepthRL)</span>
|
||||
<span class="insp-chip rating-@RatingClass(insp.RatingRR)">HR @Fmt(insp.TreadDepthRR)</span>
|
||||
</div>
|
||||
<div class="insp-details muted">
|
||||
@if (!string.IsNullOrEmpty(insp.LicensePlate)) { <span>🚗 @insp.LicensePlate</span> }
|
||||
@if (insp.Mileage.HasValue) { <span>@insp.Mileage.Value.ToString("N0", De) km</span> }
|
||||
@if (!string.IsNullOrEmpty(insp.ReportUrl)) { <a class="insp-link" href="@insp.ReportUrl" target="_blank" rel="noopener">Report ↗</a> }
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(insp.Recommendation))
|
||||
{
|
||||
<div class="insp-recommendation">💡 @insp.Recommendation</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (AllowEdit)
|
||||
{
|
||||
<QrScannerModal @bind-IsOpen="showScanner"
|
||||
Title="Reifen-Report scannen"
|
||||
Subtitle="@ScannerSubtitle"
|
||||
OnScanned="OnQrScanned" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Guid TireStackId { get; set; }
|
||||
[Parameter] public bool AllowEdit { get; set; }
|
||||
[Parameter] public bool Compact { get; set; }
|
||||
[Parameter] public bool InitiallyOpen { get; set; }
|
||||
[Parameter] public string? ScannerSubtitle { get; set; }
|
||||
[Parameter] public EventCallback<TireInspection?> OnLatestChanged { get; set; }
|
||||
|
||||
private static readonly System.Globalization.CultureInfo De = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
|
||||
private readonly string panelId = $"insp-{Guid.NewGuid():N}";
|
||||
|
||||
private Guid loadedFor;
|
||||
private bool isOpen;
|
||||
private bool loaded;
|
||||
private bool isLoading;
|
||||
private bool isBusy;
|
||||
private bool isFetching;
|
||||
private bool showScanner;
|
||||
private string? error;
|
||||
private List<TireInspection> inspections = new();
|
||||
private TireInspection? latest;
|
||||
private TireInspection? editing;
|
||||
private bool isNew;
|
||||
private Guid? confirmDeleteId;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (loadedFor != TireStackId)
|
||||
{
|
||||
loadedFor = TireStackId;
|
||||
inspections = new();
|
||||
latest = null;
|
||||
loaded = false;
|
||||
editing = null;
|
||||
confirmDeleteId = null;
|
||||
error = null;
|
||||
isOpen = InitiallyOpen;
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
inspections = await TireStackService.GetInspectionsAsync(TireStackId);
|
||||
latest = inspections.FirstOrDefault();
|
||||
loaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Messungen konnten nicht geladen werden: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Toggle() => isOpen = !isOpen;
|
||||
|
||||
private static string RatingClass(TireMeasureRating r) => r switch
|
||||
{
|
||||
TireMeasureRating.Ok => "ok",
|
||||
TireMeasureRating.Warning => "warn",
|
||||
TireMeasureRating.Critical => "crit",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static string Fmt(decimal? mm) => mm.HasValue ? mm.Value.ToString("0.0", De) + " mm" : "–";
|
||||
|
||||
private void OpenScanner()
|
||||
{
|
||||
error = null;
|
||||
showScanner = true;
|
||||
}
|
||||
|
||||
private async Task OnQrScanned(string text)
|
||||
{
|
||||
showScanner = false;
|
||||
|
||||
if (!TireReportService.TryParseReportUrl(text, out var recordSn, out _))
|
||||
{
|
||||
error = "Der QR-Code enthält keinen gültigen Reifen-Report-Link.";
|
||||
return;
|
||||
}
|
||||
|
||||
isFetching = true;
|
||||
error = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var report = await TireReportService.FetchAsync(recordSn);
|
||||
report.RecomputeRatings();
|
||||
editing = report.ToInspection(TireStackId, UserSession.CurrentUser?.Id);
|
||||
isNew = true;
|
||||
}
|
||||
catch (TireReportException ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Report konnte nicht verarbeitet werden: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartManual()
|
||||
{
|
||||
error = null;
|
||||
confirmDeleteId = null;
|
||||
isNew = true;
|
||||
editing = new TireInspection
|
||||
{
|
||||
TireStackId = TireStackId,
|
||||
Source = "Manuell",
|
||||
MeasuredAt = DateTime.UtcNow,
|
||||
CreatedByUserId = UserSession.CurrentUser?.Id
|
||||
};
|
||||
}
|
||||
|
||||
private void StartEdit(TireInspection insp)
|
||||
{
|
||||
error = null;
|
||||
confirmDeleteId = null;
|
||||
isNew = false;
|
||||
editing = new TireInspection
|
||||
{
|
||||
Id = insp.Id,
|
||||
TireStackId = insp.TireStackId,
|
||||
Source = insp.Source,
|
||||
RecordSn = insp.RecordSn,
|
||||
ReportUrl = insp.ReportUrl,
|
||||
MeasuredAt = insp.MeasuredAt,
|
||||
LicensePlate = insp.LicensePlate,
|
||||
Mileage = insp.Mileage,
|
||||
TreadDepthFL = insp.TreadDepthFL,
|
||||
TreadDepthFR = insp.TreadDepthFR,
|
||||
TreadDepthRL = insp.TreadDepthRL,
|
||||
TreadDepthRR = insp.TreadDepthRR,
|
||||
Recommendation = insp.Recommendation,
|
||||
RawJson = insp.RawJson,
|
||||
CreatedAt = insp.CreatedAt,
|
||||
CreatedByUserId = insp.CreatedByUserId
|
||||
};
|
||||
}
|
||||
|
||||
private void OnDateChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (editing == null) return;
|
||||
if (DateTime.TryParse(e.Value?.ToString(), out var d))
|
||||
editing.MeasuredAt = DateTime.SpecifyKind(d, DateTimeKind.Local).ToUniversalTime();
|
||||
else
|
||||
editing.MeasuredAt = null;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
editing = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (editing == null) return;
|
||||
|
||||
isBusy = true;
|
||||
error = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
editing.RatingFL = TireInspection.RateDepth(editing.TreadDepthFL);
|
||||
editing.RatingFR = TireInspection.RateDepth(editing.TreadDepthFR);
|
||||
editing.RatingRL = TireInspection.RateDepth(editing.TreadDepthRL);
|
||||
editing.RatingRR = TireInspection.RateDepth(editing.TreadDepthRR);
|
||||
editing.LicensePlate = string.IsNullOrWhiteSpace(editing.LicensePlate) ? null : editing.LicensePlate.Trim();
|
||||
editing.Recommendation = string.IsNullOrWhiteSpace(editing.Recommendation) ? null : editing.Recommendation.Trim();
|
||||
|
||||
if (isNew)
|
||||
await TireStackService.AddInspectionAsync(editing);
|
||||
else
|
||||
await TireStackService.UpdateInspectionAsync(editing);
|
||||
|
||||
editing = null;
|
||||
await LoadAsync();
|
||||
await OnLatestChanged.InvokeAsync(latest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Speichern fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(TireInspection insp)
|
||||
{
|
||||
isBusy = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
await TireStackService.DeleteInspectionAsync(insp.Id);
|
||||
confirmDeleteId = null;
|
||||
await LoadAsync();
|
||||
await OnLatestChanged.InvokeAsync(latest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Löschen fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
.insp-panel {
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
margin-top: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insp-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
}
|
||||
|
||||
.insp-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.insp-toggle-icon {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.insp-toggle-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.insp-toggle-summary {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insp-toggle-summary.muted {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.insp-chevron {
|
||||
transition: transform 0.15s;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.insp-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.insp-body {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-top: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.insp-panel.compact .insp-toggle {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.insp-panel.compact .insp-body {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.insp-status,
|
||||
.insp-empty {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.insp-error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 0.5px solid rgba(239, 68, 68, 0.4);
|
||||
color: #fca5a5;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.insp-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.insp-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.insp-chip {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insp-chip.rating-ok { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.4); color: #86efac; }
|
||||
.insp-chip.rating-warn { background: rgba(250, 204, 21, 0.15); border-color: rgba(250, 204, 21, 0.4); color: #fde047; }
|
||||
.insp-chip.rating-crit { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
|
||||
|
||||
.insp-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.insp-item {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insp-item.latest {
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
}
|
||||
|
||||
.insp-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.insp-date {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insp-badge {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.insp-item-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #60a5fa;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.btn-link:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.insp-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.insp-details {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.insp-link {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.insp-recommendation {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
border: 0.5px solid rgba(96, 165, 250, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* Edit form */
|
||||
.insp-edit {
|
||||
border: 0.5px solid rgba(96, 165, 250, 0.35);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: rgba(96, 165, 250, 0.05);
|
||||
}
|
||||
|
||||
.insp-edit h4 {
|
||||
margin: 0;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.insp-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-group label,
|
||||
.tread-cell label {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
padding: 6px 10px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.tread-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tread-cell {
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.tread-cell.rating-ok { border-color: rgba(34, 197, 94, 0.5); background: rgba(34, 197, 94, 0.08); }
|
||||
.tread-cell.rating-warn { border-color: rgba(250, 204, 21, 0.5); background: rgba(250, 204, 21, 0.08); }
|
||||
.tread-cell.rating-crit { border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.08); }
|
||||
|
||||
.tread-cell label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tread-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tread-input .form-control {
|
||||
max-width: 100px;
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tread-input span {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.insp-edit-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.insp-edit-footer .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: insp-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes insp-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.insp-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.insp-toggle-summary {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||
<PackageReference Include="QRCoder" Version="1.7.0" />
|
||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -128,6 +128,7 @@ builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<ILocationCodeService, LocationCodeService>();
|
||||
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
|
||||
builder.Services.AddScoped<ITireStackService, TireStackService>();
|
||||
builder.Services.AddScoped<ICustomerMergeService, CustomerMergeService>();
|
||||
|
||||
// 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<ILexwareSyncService, LexwareSyncService>();
|
||||
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();
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -31,6 +31,8 @@ public interface ITireStackService
|
||||
Task<TireInspection> AddInspectionAsync(TireInspection inspection);
|
||||
Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId);
|
||||
Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId);
|
||||
Task<TireInspection> UpdateInspectionAsync(TireInspection inspection);
|
||||
Task<bool> DeleteInspectionAsync(Guid inspectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -575,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
|
||||
@@ -596,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,
|
||||
@@ -636,6 +638,38 @@ public class TireStackService : ITireStackService
|
||||
return inspection;
|
||||
}
|
||||
|
||||
public async Task<TireInspection> UpdateInspectionAsync(TireInspection inspection)
|
||||
{
|
||||
var existing = await _context.TireInspections.FirstOrDefaultAsync(i => i.Id == inspection.Id)
|
||||
?? throw new InvalidOperationException("Messung nicht gefunden.");
|
||||
|
||||
existing.MeasuredAt = inspection.MeasuredAt;
|
||||
existing.LicensePlate = inspection.LicensePlate;
|
||||
existing.Mileage = inspection.Mileage;
|
||||
existing.TreadDepthFL = inspection.TreadDepthFL;
|
||||
existing.TreadDepthFR = inspection.TreadDepthFR;
|
||||
existing.TreadDepthRL = inspection.TreadDepthRL;
|
||||
existing.TreadDepthRR = inspection.TreadDepthRR;
|
||||
existing.RatingFL = TireInspection.RateDepth(inspection.TreadDepthFL);
|
||||
existing.RatingFR = TireInspection.RateDepth(inspection.TreadDepthFR);
|
||||
existing.RatingRL = TireInspection.RateDepth(inspection.TreadDepthRL);
|
||||
existing.RatingRR = TireInspection.RateDepth(inspection.TreadDepthRR);
|
||||
existing.Recommendation = inspection.Recommendation;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteInspectionAsync(Guid inspectionId)
|
||||
{
|
||||
var existing = await _context.TireInspections.FirstOrDefaultAsync(i => i.Id == inspectionId);
|
||||
if (existing == null) return false;
|
||||
|
||||
_context.TireInspections.Remove(existing);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId)
|
||||
{
|
||||
return await _context.TireInspections
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
Reference in new issue
Block a user