Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user