Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes

This commit is contained in:
spaethf committed 2026-09-26 23:24:14 +02:00
1 parent cba9414e92
commit 3404fb9875
25 files changed
+5106 -744

No files matched your search

@@ -0,0 +1,161 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
@if (IsOpen)
{
<div class="qr-overlay" @onclick="Cancel">
<div class="qr-dialog" role="dialog" aria-modal="true" aria-labelledby="qr-title" @onclick:stopPropagation="true">
<div class="qr-header">
<div>
<h3 id="qr-title">@Title</h3>
@if (!string.IsNullOrEmpty(Subtitle))
{
<p class="qr-subtitle">@Subtitle</p>
}
</div>
<button type="button" class="qr-close" @onclick="Cancel" aria-label="Schließen">✕</button>
</div>
<div class="qr-body">
<div id="@_elementId" class="qr-viewport"></div>
@if (isStarting)
{
<div class="qr-status"><span class="spinner"></span> Kamera wird gestartet…</div>
}
else if (!string.IsNullOrEmpty(cameraError))
{
<div class="qr-status error">📷 @cameraError</div>
}
else
{
<div class="qr-status">QR-Code des Reports vor die Kamera halten</div>
}
<div class="qr-manual">
<label for="qr-manual-input">Oder Link / Nummer einfügen</label>
<div class="qr-manual-row">
<input id="qr-manual-input" type="text" class="form-control" placeholder="https://files.cnlaunch.com/…?dataRecordSn=…"
@bind="manualText" @bind:event="oninput" @onkeydown="OnManualKeyDown" />
<button type="button" class="btn btn-primary" @onclick="SubmitManual" disabled="@string.IsNullOrWhiteSpace(manualText)">Übernehmen</button>
</div>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public string Title { get; set; } = "QR-Code scannen";
[Parameter] public string? Subtitle { get; set; }
[Parameter] public EventCallback<string> OnScanned { get; set; }
private readonly string _elementId = $"qr-reader-{Guid.NewGuid():N}";
private DotNetObjectReference<QrScannerModal>? _selfRef;
private bool _scannerActive;
private bool _wasOpen;
private bool isStarting;
private string? cameraError;
private string manualText = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (IsOpen && !_wasOpen)
{
_wasOpen = true;
await StartScanner();
}
else if (!IsOpen && _wasOpen)
{
_wasOpen = false;
await StopScanner();
}
}
private async Task StartScanner()
{
cameraError = null;
isStarting = true;
manualText = "";
StateHasChanged();
try
{
_selfRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("qrScanner.start", _elementId, _selfRef);
_scannerActive = true;
}
catch (JSException ex)
{
cameraError = ExtractMessage(ex.Message);
}
catch (Exception ex)
{
cameraError = ex.Message;
}
finally
{
isStarting = false;
StateHasChanged();
}
}
private static string ExtractMessage(string raw)
{
if (raw.Contains("NotAllowedError", StringComparison.OrdinalIgnoreCase) || raw.Contains("Permission", StringComparison.OrdinalIgnoreCase))
return "Kamerazugriff wurde verweigert. Bitte Berechtigung im Browser erlauben.";
if (raw.Contains("NotFoundError", StringComparison.OrdinalIgnoreCase) || raw.Contains("Keine Kamera", StringComparison.OrdinalIgnoreCase))
return "Keine Kamera gefunden.";
var idx = raw.IndexOf('\n');
return idx > 0 ? raw[..idx] : raw;
}
private async Task StopScanner()
{
if (!_scannerActive) return;
_scannerActive = false;
try { await JS.InvokeVoidAsync("qrScanner.stop"); } catch { }
}
[JSInvokable]
public async Task OnQrScanned(string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
await Close();
await OnScanned.InvokeAsync(text.Trim());
}
private async Task SubmitManual()
{
if (string.IsNullOrWhiteSpace(manualText)) return;
var text = manualText.Trim();
await Close();
await OnScanned.InvokeAsync(text);
}
private async Task OnManualKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") await SubmitManual();
}
private async Task Cancel() => await Close();
private async Task Close()
{
await StopScanner();
_wasOpen = false;
if (IsOpen)
{
IsOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
}
public async ValueTask DisposeAsync()
{
await StopScanner();
_selfRef?.Dispose();
}
}
@@ -0,0 +1,142 @@
.qr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1200;
}
.qr-dialog {
background: #1c1c1e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.qr-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);
}
.qr-header h3 {
margin: 0;
font-family: 'Rajdhani', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: #ffffff;
}
.qr-subtitle {
margin: 0.2rem 0 0;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
}
.qr-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.qr-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.qr-body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.qr-viewport {
width: 100%;
min-height: 260px;
border-radius: 12px;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.1);
}
::deep .qr-viewport video {
width: 100% !important;
border-radius: 12px;
}
::deep .qr-viewport img[alt="Info icon"],
::deep .qr-viewport img[alt="Camera based scan"] {
display: none;
}
.qr-status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: 'Rajdhani', sans-serif;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.qr-status.error {
color: #f87171;
}
.qr-status .spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #dc2626;
border-radius: 50%;
animation: qr-spin 0.8s linear infinite;
}
@keyframes qr-spin {
to { transform: rotate(360deg); }
}
.qr-manual {
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
padding-top: 0.9rem;
}
.qr-manual label {
display: block;
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 0.4rem;
}
.qr-manual-row {
display: flex;
gap: 0.5rem;
}
.qr-manual-row .form-control {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,190 @@
@using Lagerbestand.Models
@using Lagerbestand.Services
@inject IStorageLocationService StorageLocationService
@if (IsOpen)
{
<div class="picker-overlay" @onclick="Cancel">
<div class="picker-dialog" @onclick:stopPropagation="true" role="dialog" aria-modal="true" aria-labelledby="picker-title">
<header class="picker-header">
<div>
<h2 id="picker-title">Lagerplatz wählen</h2>
@if (!string.IsNullOrEmpty(Subtitle))
{
<p class="picker-subtitle">@Subtitle</p>
}
</div>
<button type="button" class="picker-close" @onclick="Cancel" aria-label="Schließen">✕</button>
</header>
<div class="picker-body">
@if (isLoading)
{
<div class="picker-loading" role="status">Lade Lagerplätze...</div>
}
else
{
<div class="rack-view">
@foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
{
<div class="building-section" @key="area">
<h3 class="building-title">@area.GetBuildingLabel()</h3>
@for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
{
var currentRow = row;
<div class="rack-row" @key="@($"{area}-{currentRow}")">
<h4 class="row-title">Reihe @currentRow</h4>
<div class="rack-sides">
@foreach (var side in new[] { "Links", "Rechts" })
{
<div class="rack-side" @key="side">
<span class="side-label">@side</span>
@foreach (var pos in ShelfPositionExtensions.All.Where(p => p.IsLeft() == (side == "Links")).OrderByDescending(p => p.IsTop()))
{
<div class="rack-level" @key="pos">
<span class="level-label">@(pos.IsTop() ? "Oben" : "Unten")</span>
<div class="rack-cells">
@for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
var loc = GetLocation(area, currentRow, pos, slot);
var isHighlighted = loc != null && HighlightLocationId == loc.Id;
var isSelectable = loc != null && (isHighlighted || (loc.IsAvailable && !loc.IsOccupied));
var cellClass = loc == null ? "missing" : isHighlighted ? "freeing" : !loc.IsAvailable ? "blocked" : loc.IsOccupied ? "occupied" : "free";
<button type="button"
class="rack-cell @cellClass @(pendingLocation?.Id == loc?.Id ? "selected" : "")"
@key="slot"
disabled="@(!isSelectable)"
title="@GetTooltip(loc, area, currentRow, pos, slot, isHighlighted)"
@onclick="() => Select(loc)">
@slot
</button>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
</div>
}
</div>
<div class="rack-legend" aria-hidden="true">
<span class="legend-item"><span class="legend-swatch free"></span> Frei</span>
@if (HighlightLocationId.HasValue)
{
<span class="legend-item"><span class="legend-swatch freeing"></span> Wird frei</span>
}
<span class="legend-item"><span class="legend-swatch occupied"></span> Belegt</span>
<span class="legend-item"><span class="legend-swatch blocked"></span> Gesperrt</span>
</div>
}
</div>
<footer class="picker-footer">
<div class="picker-selection">
@if (pendingLocation != null)
{
<span class="picker-selection-label">Ausgewählt:</span>
<LocationDisplay Code="@pendingLocation.Code" DisplayMode="LocationDisplayMode.Full" Size="LocationDisplaySize.Small" />
}
else
{
<span class="picker-selection-hint">Bitte einen freien Platz anklicken</span>
}
</div>
<div class="picker-actions">
<button type="button" class="btn btn-outline" @onclick="Cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" @onclick="Confirm" disabled="@(pendingLocation == null)">Übernehmen</button>
</div>
</footer>
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
/// <summary>Currently selected location (pre-selected when opening).</summary>
[Parameter] public Guid? SelectedLocationId { get; set; }
/// <summary>Location that is about to be freed (e.g. during a swap) – shown as selectable.</summary>
[Parameter] public Guid? HighlightLocationId { get; set; }
[Parameter] public string? Subtitle { get; set; }
[Parameter] public EventCallback<StorageLocation> OnSelected { get; set; }
private bool isLoading;
private bool wasOpen;
private List<StorageLocation> allLocations = new();
private Dictionary<(AreaType, int, ShelfPosition, int), StorageLocation> lookup = new();
private StorageLocation? pendingLocation;
protected override async Task OnParametersSetAsync()
{
if (IsOpen && !wasOpen)
{
wasOpen = true;
await LoadAsync();
}
else if (!IsOpen)
{
wasOpen = false;
}
}
private async Task LoadAsync()
{
isLoading = true;
try
{
allLocations = await StorageLocationService.GetAllAsync();
lookup = allLocations.ToDictionary(l => (l.Area, l.Row, l.Position, l.SlotNumber));
pendingLocation = SelectedLocationId.HasValue
? allLocations.FirstOrDefault(l => l.Id == SelectedLocationId.Value)
: null;
}
finally
{
isLoading = false;
}
}
private StorageLocation? GetLocation(AreaType area, int row, ShelfPosition pos, int slot)
=> lookup.TryGetValue((area, row, pos, slot), out var loc) ? loc : null;
private void Select(StorageLocation? loc)
{
if (loc == null) return;
pendingLocation = loc;
}
private async Task Confirm()
{
if (pendingLocation == null) return;
await OnSelected.InvokeAsync(pendingLocation);
await Close();
}
private Task Cancel() => Close();
private async Task Close()
{
IsOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
private string GetTooltip(StorageLocation? loc, AreaType area, int row, ShelfPosition pos, int slot, bool isHighlighted)
{
var baseText = $"{area.GetDisplayName()}, Reihe {row}, {pos.GetDisplayName()}, Platz {slot}";
if (loc == null) return $"{baseText} – nicht angelegt";
if (isHighlighted) return $"{baseText} – wird frei";
if (!loc.IsAvailable) return $"{baseText} – gesperrt";
if (loc.IsOccupied) return $"{baseText} – belegt";
return $"{baseText} – frei";
}
}
@@ -0,0 +1,309 @@
/* ========================================
Overlay & Dialog
======================================== */
.picker-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: 1100;
}
.picker-dialog {
background: #1c1c1e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
width: 100%;
max-width: 1100px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-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);
}
.picker-header h2 {
margin: 0;
font-family: 'Rajdhani', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: #ffffff;
}
.picker-subtitle {
margin: 0.2rem 0 0;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
}
.picker-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.picker-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.picker-body {
padding: 1rem 1.25rem;
overflow-y: auto;
flex: 1;
}
.picker-loading {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Rajdhani', sans-serif;
}
.picker-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.85rem 1.25rem;
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
}
.picker-selection {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'Rajdhani', sans-serif;
}
.picker-selection-label {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.picker-selection-hint {
color: rgba(255, 255, 255, 0.45);
}
.picker-actions {
display: flex;
gap: 0.5rem;
}
/* ========================================
Rack Grid (mirrors TireStorage rack view)
======================================== */
.rack-view {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
@media (min-width: 1024px) {
.rack-view {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.building-section {
background: rgba(28, 28, 30, 0.8);
border: 0.5px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
width: 100%;
}
.building-title {
font-family: 'Rajdhani', sans-serif;
font-size: 1.05rem;
font-weight: 600;
color: #ffffff;
margin: 0 0 0.75rem 0;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.rack-row {
margin-bottom: 0.75rem;
}
.row-title {
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 0.3rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rack-sides {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 767.98px) {
.rack-sides {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
.rack-side {
min-width: 0;
}
.side-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 0.2rem;
}
.rack-level {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.2rem 0;
}
.level-label {
width: 2.6rem;
flex-shrink: 0;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.4);
}
.rack-cells {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 4px;
flex: 1;
min-width: 0;
}
.rack-cell {
width: 100%;
min-height: 2rem;
border-radius: 6px;
border: 1px solid transparent;
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
padding: 0;
}
.rack-cell:not(:disabled):hover {
transform: scale(1.1);
}
.rack-cell.free {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #4ade80;
}
.rack-cell.freeing {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(96, 165, 250, 0.5);
color: #93c5fd;
}
.rack-cell.occupied {
background: rgba(220, 38, 38, 0.2);
border-color: rgba(220, 38, 38, 0.45);
color: #f87171;
cursor: not-allowed;
}
.rack-cell.blocked {
background: rgba(148, 163, 184, 0.15);
border-color: rgba(148, 163, 184, 0.3);
color: rgba(255, 255, 255, 0.35);
cursor: not-allowed;
}
.rack-cell.missing {
background: transparent;
border: 1px dashed rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.2);
cursor: default;
}
.rack-cell.selected {
outline: 2px solid #22c55e;
outline-offset: 1px;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
}
/* ========================================
Legend
======================================== */
.rack-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.75rem;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Rajdhani', sans-serif;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.legend-swatch {
width: 0.9rem;
height: 0.9rem;
border-radius: 4px;
display: inline-block;
}
.legend-swatch.free {
background: rgba(34, 197, 94, 0.4);
}
.legend-swatch.freeing {
background: rgba(96, 165, 250, 0.5);
}
.legend-swatch.occupied {
background: rgba(220, 38, 38, 0.45);
}
.legend-swatch.blocked {
background: rgba(148, 163, 184, 0.35);
}