WerksverkaufScanner:

-Inventur vorbereiten entfernt
-Einstellungsseite ergänzt
-Filial-Id anhand IP laden
This commit is contained in:
Christopher Meinhold 2025-12-03 14:11:03 +01:00
parent 19fe31233d
commit 1729550f95
17 changed files with 828 additions and 150 deletions

View File

@ -1,25 +1,49 @@
using Microsoft.AspNetCore.Authentication; using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims; using WerksverkaufScanner.Services; // <-- für IpFilialeService
[ApiController] [ApiController]
[Route("auth")] [Route("auth")]
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly AuthService _auth; private readonly AuthService _auth;
public AuthController(AuthService auth) => _auth = auth; private readonly IpFilialeService _ipFiliale;
public AuthController(AuthService auth, IpFilialeService ipFiliale)
{
_auth = auth;
_ipFiliale = ipFiliale;
}
// Form-Login: aus <form method="post" action="/auth/login?returnUrl=..."> // Form-Login: aus <form method="post" action="/auth/login?returnUrl=...">
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login([FromForm] string kassiererCode, [FromQuery] string? returnUrl) public async Task<IActionResult> Login([FromForm] string kassiererCode, [FromQuery] string? returnUrl)
{ {
var user = await _auth.ValidateAsync(kassiererCode ?? ""); var user = await _auth.ValidateAsync(kassiererCode ?? string.Empty);
if (user is null) if (user is null)
return Unauthorized("Ungültiger Code oder Benutzer inaktiv."); return Unauthorized("Ungültiger Code oder Benutzer inaktiv.");
// 1) Client-IP ermitteln (Reverse-Proxy: X-Forwarded-For beachten)
#if DEBUG
var clientIp = IPAddress.Parse("192.168.118.5");
#else
var clientIp = GetClientIp(HttpContext);
#endif
// 2) IP -> FilialId (optional)
int? filialId = null;
if (clientIp != null)
filialId = await _ipFiliale.TryMatchFilialeAsync(clientIp);
// 3) Claims zusammenstellen
var claims = new List<Claim> var claims = new List<Claim>
{ {
new Claim(ClaimTypes.NameIdentifier, user.KassiererId.ToString()), new Claim(ClaimTypes.NameIdentifier, user.KassiererId.ToString()),
@ -28,12 +52,15 @@ public class AuthController : ControllerBase
new Claim("rollenId", user.RollenId.ToString()) new Claim("rollenId", user.RollenId.ToString())
}; };
if (filialId.HasValue)
claims.Add(new Claim("filialId", filialId.Value.ToString()));
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
// Nach Erfolg zurück zur gewünschten Seite // 4) Zurück zur Zielseite
if (string.IsNullOrWhiteSpace(returnUrl) || !Url.IsLocalUrl(returnUrl)) if (string.IsNullOrWhiteSpace(returnUrl) || !Url.IsLocalUrl(returnUrl))
return LocalRedirect("/"); return LocalRedirect("/");
@ -42,13 +69,13 @@ public class AuthController : ControllerBase
[Authorize] [Authorize]
[HttpPost("/auth/logout")] [HttpPost("/auth/logout")]
public IActionResult Logout() public async Task<IActionResult> Logout()
{ {
// Serverseitig abmelden // Serverseitig abmelden (await nicht vergessen!)
HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Antwort mit JS, das localStorage löscht und weiterleitet // Client: localStorage aufräumen & weiterleiten
var html = """ const string html = """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head><meta charset="utf-8"><title>Logout</title></head> <head><meta charset="utf-8"><title>Logout</title></head>
@ -67,4 +94,18 @@ public class AuthController : ControllerBase
return Content(html, "text/html"); return Content(html, "text/html");
} }
private static IPAddress? GetClientIp(HttpContext ctx)
{
// Falls hinter Reverse-Proxy: erstes X-Forwarded-For nehmen
var xff = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(xff))
{
var first = xff.Split(',').Select(s => s.Trim()).FirstOrDefault();
if (IPAddress.TryParse(first, out var ipFromHeader))
return ipFromHeader;
}
return ctx.Connection.RemoteIpAddress;
}
} }

View File

@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WerksverkaufScanner.Data
{
[Table("FilialienMA", Schema = "KS")]
public class Filiale
{
[Key]
public int FilialId { get; set; }
public int MandantenId { get; set; }
public string? Bezeichnung { get; set; }
public string? Level { get; set; }
public string? Straße { get; set; }
public string? PLZ { get; set; }
public string? Ort { get; set; }
public string? UstId { get; set; }
public string? StNr { get; set; }
public int WerkNr { get; set; }
public string? Telefon { get; set; }
public string? Filialleitung { get; set; }
}
}

View File

@ -7,7 +7,7 @@ namespace WerksverkaufScanner.Data;
public class InventurErfassung public class InventurErfassung
{ {
[Key] public int Id { get; set; } [Key] public int Id { get; set; }
public int InventurId { get; set; } public int InventurId { get; set; } = 0;
public string FilialId { get; set; } public string FilialId { get; set; }

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace WerksverkaufScanner.Data;
public class PreisAenderung
{
[Key] public int Id { get; set; }
public string FilialId { get; set; } = default!;
public int ArtikelId { get; set; }
public int ArtikelVariantenId { get; set; }
public int Variante { get; set; }
/// <summary>Preis in Cent (z. B. 599 = 5,99 €)</summary>
public int PreisInCents { get; set; }
public string Benutzer { get; set; } = default!;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -6,10 +6,9 @@ namespace WerksverkaufScanner.Data;
public class ScannerDb : DbContext public class ScannerDb : DbContext
{ {
public ScannerDb(DbContextOptions<ScannerDb> options) : base(options) { } public ScannerDb(DbContextOptions<ScannerDb> options) : base(options) { }
public DbSet<Artikel> Artikel => Set<Artikel>(); public DbSet<Artikel> Artikel => Set<Artikel>();
public DbSet<InventurErfassung> InventurErfassung => Set<InventurErfassung>(); public DbSet<InventurErfassung> InventurErfassung => Set<InventurErfassung>();
public DbSet<Kassierer> Kassierer { get; set; } = null!; public DbSet<Kassierer> Kassierer { get; set; } = null!;
public DbSet<PreisAenderung> PreisAenderungen => Set<PreisAenderung>();
public DbSet<Filiale> Filialen => Set<Filiale>();
} }

View File

@ -0,0 +1,61 @@
@page "/einstellungen"
@attribute [Authorize]
<PageTitle>ScannerPilot - Einstellungen</PageTitle>
<h2>Einstellungen</h2>
@if (loading)
{
<p>Lade aktuelle Einstellungen …</p>
}
else
{
<dl class="row">
<dt class="col-sm-3">Aktuelle FilialID</dt>
<dd class="col-sm-9">@currentFilialId (@currentFilialName)</dd>
<dt class="col-sm-3">Angemeldeter Benutzer</dt>
<dd class="col-sm-9">@currentUser</dd>
<dt class="col-sm-3">ClientIP</dt>
<dd class="col-sm-9">@currentIp</dd>
</dl>
}
@code {
[Inject] private FilialService FilialService { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthProvider { get; set; } = default!;
[Inject] private IJSRuntime JS { get; set; } = default!;
[Inject] private IHttpContextAccessor HttpContextAccessor { get; set; } = default!;
private string? currentUser;
private string? currentIp;
private int? currentFilialId;
private string? currentFilialName;
private bool loading = true;
protected override async Task OnInitializedAsync()
{
// Angemeldeten Benutzer ermitteln
var authState = await AuthProvider.GetAuthenticationStateAsync();
currentUser = authState.User.Identity?.Name;
// FilialID aus localStorage lesen
var filialStr = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId");
if (!string.IsNullOrWhiteSpace(filialStr) && int.TryParse(filialStr.Trim('"'), out var filialId))
{
currentFilialId = filialId;
// Filialname via DB laden
var filiale = await FilialService.GetFilialeAsync(filialId);
currentFilialName = filiale?.Bezeichnung;
}
// IP aus HttpContext/ForwardedHeaders
var ip = HttpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString();
currentIp = ip;
loading = false;
}
}

View File

@ -18,16 +18,27 @@
</div> </div>
</div> </div>
@* <div class="col"> <div class="col">
<div class="card h-100 shadow-sm border-0">
<div class="card-body text-center">
<i class="bi bi-gear display-5 text-primary mb-2"></i>
<h5>Stammdaten</h5>
<p class="text-muted">Hier können z.B. Preise etc. angepasst werden</p>
<a href="/stammdaten" class="btn btn-primary">Öffnen</a>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 shadow-sm border-0"> <div class="card h-100 shadow-sm border-0">
<div class="card-body text-center"> <div class="card-body text-center">
<i class="bi bi-gear display-5 text-primary mb-2"></i> <i class="bi bi-gear display-5 text-primary mb-2"></i>
<h5>Einstellungen</h5> <h5>Einstellungen</h5>
<p class="text-muted">Verwalte Filiale und Inventurparameter.</p> <p class="text-muted">Hier können Einstellungen wie z.B. die aktuelle IP angezeigt werden.</p>
<a href="/inventur/vorbereiten" class="btn btn-outline-primary">Anpassen</a> <a href="/einstellungen" class="btn btn-primary">Öffnen</a>
</div> </div>
</div> </div>
</div> *@ </div>
</div> </div>

View File

@ -5,9 +5,27 @@
<h2>Inventur</h2> <h2>Inventur</h2>
<p> <div class="row row-cols-1 row-cols-md-2 g-3 mt-2">
<ul> @* <div class="col">
<li><a href="/inventur/vorbereiten">Inventur vorbereiten</a> - Hier müssen die aktuellen Inventureinstellungen gescannt werden.</li> <div class="card h-100 shadow-sm border-0">
<li><a href="/inventur/scannen">Inventur scannen</a> - Hier kannst du die Artikel in die Inventurerfassung scannen.</li> <div class="card-body text-center">
</ul> <i class="bi bi-box-seam display-5 text-primary mb-2"></i>
</p> <h5>Inventur vorbereiten</h5>
<p class="text-muted">Hier müssen die aktuellen Inventureinstellungen gescannt werden.</p>
<a href="/inventur/vorbereiten" class="btn btn-primary">Weiter</a>
</div>
</div>
</div> *@
<div class="col">
<div class="card h-100 shadow-sm border-0">
<div class="card-body text-center">
<i class="bi bi-gear display-5 text-primary mb-2"></i>
<h5>Inventur scannen</h5>
<p class="text-muted">Hier kannst du die Artikel in die Inventurerfassung scannen.</p>
<a href="/inventur/scannen" class="btn btn-primary">Weiter</a>
</div>
</div>
</div>
</div>

View File

@ -114,9 +114,8 @@ else
try try
{ {
var f = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId"); var f = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId");
var i = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.inventurId");
if (!String.IsNullOrWhiteSpace(f) || !String.IsNullOrWhiteSpace(i)) if (!String.IsNullOrWhiteSpace(f))
{ {
await LoadConfigAsync(); await LoadConfigAsync();
@ -142,7 +141,6 @@ else
{ {
needsSetup = true; needsSetup = true;
filialId = null; filialId = null;
inventurId = null;
} }
} }
@ -152,25 +150,19 @@ else
try try
{ {
var f = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId"); var f = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId");
var i = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.inventurId");
var u = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.user"); var u = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.user");
filialId = f?.Trim('"'); filialId = f?.Trim('"');
currentUser = u?.Trim('"'); currentUser = u?.Trim('"');
if (!string.IsNullOrWhiteSpace(i) && int.TryParse(i.Trim('"'), out var inv))
inventurId = inv;
else
inventurId = null;
// needsSetup = true, wenn irgendwas fehlt/ungültig // needsSetup = true, wenn irgendwas fehlt/ungültig
needsSetup = string.IsNullOrWhiteSpace(filialId) || inventurId is null; needsSetup = string.IsNullOrWhiteSpace(filialId);
} }
catch catch
{ {
needsSetup = true; needsSetup = true;
filialId = null; filialId = null;
inventurId = null;
currentUser = null; currentUser = null;
} }
} }
@ -278,17 +270,14 @@ else
try try
{ {
// Sicherheitsnetz noch einmal kurz prüfen (kann durch Tabwechsel gelöscht sein) // Sicherheitsnetz noch einmal kurz prüfen (kann durch Tabwechsel gelöscht sein)
if (filialId is null || inventurId is null) if (filialId is null)
await LoadConfigAsync(); await LoadConfigAsync();
if (string.IsNullOrWhiteSpace(filialId)) if (string.IsNullOrWhiteSpace(filialId))
throw new InvalidOperationException("Filial-ID fehlt."); throw new InvalidOperationException("Filial-ID fehlt.");
if (inventurId is null)
throw new InvalidOperationException("Inventur-ID fehlt oder ungültig.");
var rows = await Inventur.SaveAsync( var rows = await Inventur.SaveAsync(
filialId, filialId,
inventurId.Value,
gefunden.ArtikelId, gefunden.ArtikelId,
gefunden.ArtikelVariantenId, gefunden.ArtikelVariantenId,
gefunden.Variante, gefunden.Variante,

View File

@ -0,0 +1,20 @@
@page "/stammdaten"
@attribute [Authorize]
<PageTitle>ScannerPilot - Stammdaten</PageTitle>
<h2>Stammdatenänderungen</h2>
<div class="row row-cols-1 row-cols-md-2 g-3 mt-2">
<div class="col">
<div class="card h-100 shadow-sm border-0">
<div class="card-body text-center">
<i class="bi bi-box-seam display-5 text-primary mb-2"></i>
<h5>Preisänderung</h5>
<p class="text-muted">Hier können die Preise der Artikel geändert werden.</p>
<a href="/stammdaten/preisaenderung" class="btn btn-primary">Weiter</a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,332 @@
@page "/stammdaten/preisaenderung"
@attribute [Authorize]
@using System.Globalization
@using WerksverkaufScanner.Data
@using WerksverkaufScanner.Services
@using Microsoft.AspNetCore.Components.Web
@inject StammdatenCache Cache
@inject PreisAenderungSqlService Preise
@inject IJSRuntime JS
<h2>Preisänderung</h2>
<!-- 1) Barcode ODER Artikelnummer -->
<input @ref="barcodeRef" id="barcode"
class="form-control form-control-lg mb-2"
placeholder="Barcode scannen oder Artikelnummer eingeben"
@bind="scanText" @bind:event="oninput"
@onkeydown="OnScanKey" />
@if (varianten is { Count: > 1 })
{
<div class="mb-3">
<div class="mb-1">Varianten gefunden bitte auswählen:</div>
<div class="d-flex flex-wrap gap-2">
@foreach (var art in varianten)
{
var isActive = ReferenceEquals(gefunden, art);
<button type="button"
class="btn btn-lg"
style="@GetButtonStyle(art, isActive)"
@onclick="@(() => VarianteAuswaehlen(art))">
@GetVarianteLabel(art)
</button>
}
</div>
</div>
}
@if (gefunden is not null)
{
<div class="alert alert-info py-2 mb-2">
<strong>@gefunden.Bezeichnung</strong>
<span class="text-muted">
· @gefunden.ArtikelNummer
· Variante @gefunden.Variante
@if (!string.IsNullOrWhiteSpace(gefunden.Farbe))
{
<text> · @gefunden.Farbe</text>
}
@if (!string.IsNullOrWhiteSpace(gefunden.Text))
{
<text> · @gefunden.Text</text>
}
</span>
</div>
<!-- 2) Preis-Eingabe (masked: 599 -> 5,99) -->
<div class="input-group input-group-lg mb-2">
<span class="input-group-text">€</span>
<input id="preis"
class="form-control text-end"
inputmode="numeric" pattern="[0-9]*"
placeholder="z. B. 599 → 5,99"
@bind="priceText" @bind:event="oninput" @bind:after="OnPriceChanged"
@onkeydown="OnPriceKey" />
</div>
<button class="btn btn-primary"
disabled="@(!priceCents.HasValue)"
@onclick="SpeichernAsync">
Speichern
</button>
}
@if (!string.IsNullOrEmpty(status))
{
<div class="mt-2 @(ok ? "text-success" : "text-danger")">@status</div>
}
@code {
// Eingaben/Status
private string? scanText;
private Artikel? gefunden;
private List<Artikel>? varianten;
private string? priceText; // angezeigter Text ("5,99")
private int? priceCents; // intern in Cent (599)
private string? status;
private bool ok;
// Fokussteuerung
private ElementReference barcodeRef, priceRef;
private bool focusBarcode = true;
private bool focusPrice;
protected override async Task OnInitializedAsync()
{
if (Cache.GetArtikel().Count == 0)
await Cache.RefreshAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (focusBarcode)
{
focusBarcode = false;
await barcodeRef.FocusAsync();
}
if (focusPrice)
{
focusPrice = false;
await JS.InvokeVoidAsync("setTimeout", (Action)(async () =>
{
await JS.InvokeVoidAsync("document.getElementById", "preis");
}), 0);
}
}
private async Task OnScanKey(KeyboardEventArgs e)
{
if (e.Key == "Enter")
await SucheArtikelAsync();
}
private async Task SucheArtikelAsync()
{
status = null;
ok = false;
gefunden = null;
varianten = null;
priceText = null;
priceCents = null;
var input = scanText?.Trim();
scanText = string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
focusBarcode = true;
StateHasChanged();
return;
}
if (Cache.GetArtikel().Count == 0)
await Cache.RefreshAsync();
var alle = Cache.GetArtikel();
var matches = alle
.Where(a =>
string.Equals(a.Barcode, input, StringComparison.OrdinalIgnoreCase) ||
string.Equals(a.ArtikelNummer, input, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.Variante)
.ThenBy(a => a.ArtikelVariantenId)
.ToList();
if (matches.Count == 0)
{
status = $"Kein Artikel gefunden für „{input}“.";
focusBarcode = true;
}
else if (matches.Count == 1)
{
gefunden = matches[0];
varianten = null;
status = $"„{gefunden.Bezeichnung}“ Variante {GetVarianteLabel(gefunden)} ausgewählt. Bitte Preis eingeben.";
focusPrice = true;
}
else
{
varianten = matches;
status = $"{matches.Count} Varianten gefunden bitte auswählen.";
}
StateHasChanged();
}
private void VarianteAuswaehlen(Artikel art)
{
gefunden = art;
status = $"Variante {GetVarianteLabel(art)} ausgewählt. Bitte Preis eingeben.";
focusPrice = true;
StateHasChanged();
}
// ---------- Preis-Masking: "599" -> "5,99" (de-DE) ----------
private void OnPriceChanged()
{
var raw = priceText ?? string.Empty;
// nur Ziffern nehmen
var digits = new string(raw.Where(char.IsDigit).ToArray());
if (string.IsNullOrEmpty(digits))
{
priceCents = null;
priceText = string.Empty;
return;
}
// führende Nullen tolerieren
if (digits.Length > 1)
digits = digits.TrimStart('0').PadLeft(1, '0');
if (!int.TryParse(digits, out var cents))
{
priceCents = null;
return;
}
priceCents = cents;
// Format de-DE mit Komma
var de = CultureInfo.GetCultureInfo("de-DE");
priceText = (cents / 100m).ToString("N2", de);
}
private async Task OnPriceKey(KeyboardEventArgs e)
{
if (e.Key == "Enter")
await SpeichernAsync();
}
private async Task SpeichernAsync()
{
status = null;
ok = false;
if (gefunden is null)
{
status = "Bitte zuerst scannen und eine Variante auswählen.";
focusBarcode = true;
StateHasChanged();
return;
}
if (!priceCents.HasValue || priceCents.Value <= 0)
{
status = "Bitte einen gültigen Preis eingeben.";
focusPrice = true;
StateHasChanged();
return;
}
try
{
// Filiale + User aus localStorage
var filialIdStr = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId");
var user = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.user");
if (string.IsNullOrWhiteSpace(filialIdStr))
throw new InvalidOperationException("Filial-ID fehlt.");
if (string.IsNullOrWhiteSpace(user))
throw new InvalidOperationException("Benutzername fehlt.");
filialIdStr = filialIdStr.Trim('"');
user = user?.Trim('"');
var rows = await Preise.SaveAsync(
filialIdStr,
gefunden.ArtikelId,
gefunden.ArtikelVariantenId,
gefunden.Variante,
priceCents!.Value,
user!
);
ok = rows > 0;
status = ok
? $"Preis gespeichert: {gefunden.ArtikelNummer} Variante {GetVarianteLabel(gefunden)} · {FormatEuro(priceCents!.Value)}."
: "Nichts gespeichert.";
// Reset für nächste Eingabe
priceText = null;
priceCents = null;
gefunden = null;
varianten = null;
focusBarcode = true;
}
catch (Exception ex)
{
ok = false;
status = "Fehler beim Speichern: " + ex.Message;
focusPrice = true;
}
StateHasChanged();
}
// ---------- Helfer ----------
private string GetVarianteLabel(Artikel art)
{
if (!string.IsNullOrWhiteSpace(art.Text)) return art.Text;
if (!string.IsNullOrWhiteSpace(art.Farbe)) return art.Farbe;
return $"Variante {art.Variante}";
}
private string GetButtonStyle(Artikel art, bool isActive)
{
var bg = GetCssColorFromFarbe(art.Farbe);
var border = isActive ? "3px solid black" : "1px solid #666";
var color = NeedsDarkText(art.Farbe) ? "black" : "white";
return $"background-color:{bg};color:{color};border:{border};min-width:6rem;";
}
private string GetCssColorFromFarbe(string? farbe)
{
if (string.IsNullOrWhiteSpace(farbe)) return "#e0e0e0";
switch (farbe.Trim().ToLowerInvariant())
{
case "rot": return "#dc3545";
case "blau": return "#007bff";
case "gruen":
case "grün": return "#28a745";
case "gelb": return "#ffc107";
case "orange": return "#fd7e14";
case "lila": return "#6f42c1";
default: return "#e0e0e0";
}
}
private bool NeedsDarkText(string? farbe)
=> string.IsNullOrWhiteSpace(farbe) || farbe.Trim().ToLowerInvariant() is "gelb" or "orange";
private static string FormatEuro(int cents)
=> (cents / 100m).ToString("C", CultureInfo.GetCultureInfo("de-DE"));
}

View File

@ -1,94 +1,18 @@
//using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
//using Microsoft.AspNetCore.HttpOverrides;
//using Microsoft.EntityFrameworkCore;
//using WerksverkaufScanner.Data;
//using WerksverkaufScanner.Services;
//var builder = WebApplication.CreateBuilder(args);
//// Optional: explizit Port/Bind-Adresse setzen
//builder.WebHost.UseUrls("https://0.0.0.0:3300");
////builder.WebHost.UseUrls("http://0.0.0.0:3300");
//// 1) ConnectionString prüfen
//var cs = builder.Configuration.GetConnectionString("Default");
//if (string.IsNullOrWhiteSpace(cs))
// throw new InvalidOperationException("ConnectionStrings:Default fehlt oder ist leer.");
//// 2) Framework-Services
//builder.Services.AddRazorPages();
//builder.Services.AddServerSideBlazor();
//builder.Services.AddControllers(); // für AuthController (Login/Logout)
//builder.Services.AddHttpClient(); // für Logout/Calls aus Layout/Login
//builder.Services.AddHttpContextAccessor(); // falls Services HttpContext brauchen
//// 3) Auth/Authorization (Cookie)
//builder.Services
// .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
// .AddCookie(o =>
// {
// o.LoginPath = "/login"; // ohne Auth -> Redirect hierher
// o.AccessDeniedPath = "/login";
// o.ReturnUrlParameter = "returnUrl";
// o.SlidingExpiration = true;
// o.ExpireTimeSpan = TimeSpan.FromDays(7);
// // Cookie-Härtung
// o.Cookie.Name = "Werksverkauf.Auth";
// o.Cookie.HttpOnly = true;
// o.Cookie.SecurePolicy = CookieSecurePolicy.None; // nur über HTTPS eigentlich .Always
// o.Cookie.SameSite = SameSiteMode.Lax; // für normale Navigations-Requests
// });
//builder.Services.AddAuthorization();
//// 4) App-Services & Datenzugriff
//builder.Services.AddDbContextFactory<ScannerDb>(opt => opt.UseSqlServer(cs));
//builder.Services.AddSingleton<StammdatenCache>();
//builder.Services.AddScoped<InventurService>();
//builder.Services.AddScoped<AuthService>(); // prüft KassenLogin in DB
//// (Optional) Forwarded Headers, wenn hinter Proxy/LoadBalancer (NGINX/IIS/K8s)
//builder.Services.Configure<ForwardedHeadersOptions>(opt =>
//{
// opt.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// // opt.KnownProxies.Add(IPAddress.Parse("127.0.0.1")); // bei Bedarf setzen
//});
//var app = builder.Build();
//// 5) Middleware-Pipeline
//app.UseForwardedHeaders(); // vor allem anderen, wenn Proxy im Spiel
//app.UseStaticFiles();
//app.UseRouting();
//app.UseAuthentication(); // erst Auth…
//app.UseAuthorization(); // …dann Authorization
//// 6) Endpoints
//app.MapControllers(); // /auth/login, /auth/logout, /auth/me
//app.MapBlazorHub();
//app.MapFallbackToPage("/_Host");
//app.Run();
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using WerksverkaufScanner.Data; using WerksverkaufScanner.Data;
using WerksverkaufScanner.Services; using WerksverkaufScanner.Services;
// using System.Net; // nur nötig, wenn du KnownProxies/KnownNetworks setzt
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- WICHTIG FÜR IIS: KEIN eigenes HTTPS/UseUrls hier setzen! --- // --- WICHTIG FÜR IIS/REVERSE PROXY ---
// Kestrel lauscht hinter IIS nicht selbst auf :443. TLS terminiert im IIS. // KEIN eigenes HTTPS/UseUrls setzen; TLS terminiert im IIS/Proxy.
Entfernt: builder.WebHost.UseUrls("https://0.0.0.0:3300"); // Falls du explizit klarstellen willst, dass IIS verwendet wird:
builder.WebHost.UseUrls("https://0.0.0.0:3300");
//builder.WebHost.UseIIS();
// Optional (schadet nicht, macht die Absicht klar): // 1) ConnectionString prüfen
builder.WebHost.UseIIS();
// 1) ConnectionString prüfen
var cs = builder.Configuration.GetConnectionString("Default"); var cs = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(cs)) if (string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("ConnectionStrings:Default fehlt oder ist leer."); throw new InvalidOperationException("ConnectionStrings:Default fehlt oder ist leer.");
@ -96,9 +20,9 @@ if (string.IsNullOrWhiteSpace(cs))
// 2) Framework-Services // 2) Framework-Services
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(); builder.Services.AddServerSideBlazor();
builder.Services.AddControllers(); // für AuthController (Login/Logout) builder.Services.AddControllers(); // AuthController (Login/Logout)
builder.Services.AddHttpClient(); // für Logout/Calls aus Layout/Login builder.Services.AddHttpClient(); // HTTP-Calls (optional)
builder.Services.AddHttpContextAccessor(); // falls Services HttpContext brauchen builder.Services.AddHttpContextAccessor(); // wenn Services HttpContext brauchen
// 3) Auth/Authorization (Cookie) // 3) Auth/Authorization (Cookie)
builder.Services builder.Services
@ -109,13 +33,12 @@ builder.Services
o.AccessDeniedPath = "/login"; o.AccessDeniedPath = "/login";
o.ReturnUrlParameter = "returnUrl"; o.ReturnUrlParameter = "returnUrl";
o.SlidingExpiration = true; o.SlidingExpiration = true;
o.ExpireTimeSpan = TimeSpan.FromHours(15); //somit AutoLogoff nach 15 Stunden o.ExpireTimeSpan = TimeSpan.FromHours(15); // Auto-Logout nach 15h
// Cookie-Härtung: // Cookie-Härtung:
o.Cookie.Name = "Werksverkauf.Auth"; o.Cookie.Name = "Werksverkauf.Auth";
o.Cookie.HttpOnly = true; o.Cookie.HttpOnly = true;
// Hinter IIS wird über HTTPS ausgeliefert -> Always ist korrekt o.Cookie.SecurePolicy = CookieSecurePolicy.Always; // hinter IIS ok
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
o.Cookie.SameSite = SameSiteMode.Lax; o.Cookie.SameSite = SameSiteMode.Lax;
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
@ -125,20 +48,23 @@ builder.Services.AddDbContextFactory<ScannerDb>(opt => opt.UseSqlServer(cs));
builder.Services.AddSingleton<StammdatenCache>(); builder.Services.AddSingleton<StammdatenCache>();
builder.Services.AddScoped<InventurService>(); builder.Services.AddScoped<InventurService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<PreisAenderungSqlService>();
builder.Services.AddScoped<IpFilialeService>(); // <— für IP→Filiale-Mapping
builder.Services.AddScoped<FilialService>();
// (Optional) Forwarded Headers sinnvoll hinter IIS/Proxy // (Optional) Forwarded Headers sinnvoll hinter IIS/Proxy
builder.Services.Configure<ForwardedHeadersOptions>(opt => builder.Services.Configure<ForwardedHeadersOptions>(opt =>
{ {
opt.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; opt.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Falls du strikte Proxy-Liste willst: // Falls du strikt nur bestimmte Proxys erlauben willst:
// opt.KnownProxies.Add(IPAddress.Parse("10.250.1.30")); // opt.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
}); });
var app = builder.Build(); var app = builder.Build();
// 5) Middleware-Pipeline // 5) Middleware-Pipeline
app.UseForwardedHeaders(); // vor allem anderen, wenn Proxy im Spiel app.UseForwardedHeaders(); // vor HttpsRedirection/Authentication
app.UseHttpsRedirection(); // ok hinter IIS, nutzt X-Forwarded-Proto app.UseHttpsRedirection(); // nutzt X-Forwarded-Proto hinter Proxy
app.UseStaticFiles(); app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
@ -151,11 +77,10 @@ app.MapControllers();
app.MapBlazorHub(); app.MapBlazorHub();
app.MapFallbackToPage("/_Host"); app.MapFallbackToPage("/_Host");
// --- Für lokalen Start (ohne IIS) z. B. auf HTTP-Port 3300 testen --- // --- Nur für lokalen Start OHNE IIS, wenn du Kestrel direkt nutzen willst ---
// if (!app.Environment.IsProduction()) // if (!app.Environment.IsProduction())
// { // {
// app.Urls.Add("http://localhost:3300"); // app.Urls.Add("http://localhost:3300");
// } // }
app.Run(); app.Run();

View File

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using WerksverkaufScanner.Data;
namespace WerksverkaufScanner.Services
{
public class FilialService
{
private readonly IDbContextFactory<ScannerDb> _dbFactory;
public FilialService(IDbContextFactory<ScannerDb> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<Filiale?> GetFilialeAsync(int filialId)
{
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Set<Filiale>()
.AsNoTracking()
.FirstOrDefaultAsync(f => f.FilialId == filialId);
}
}
}

View File

@ -20,7 +20,7 @@ public class InventurService
_filialId = config.GetValue<int>("FilialId"); _filialId = config.GetValue<int>("FilialId");
} }
public async Task<int> SaveAsync(string filialId, int inventurId, int artikelId, int artikelVariantenId, int variante, int menge, string user) public async Task<int> SaveAsync(string filialId, int artikelId, int artikelVariantenId, int variante, int menge, string user)
{ {
var clientIp = GetClientIp(); var clientIp = GetClientIp();
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@ -29,7 +29,7 @@ public class InventurService
{ {
FilialId = filialId, FilialId = filialId,
InventurId = inventurId, InventurId = 0,
ArtikelId = artikelId, ArtikelId = artikelId,
ArtikelVariantenId = artikelVariantenId, ArtikelVariantenId = artikelVariantenId,
Variante = variante, Variante = variante,

View File

@ -0,0 +1,74 @@
// Services/IpFilialeService.cs
using System.Net;
using Microsoft.Data.SqlClient;
using System.Data;
namespace WerksverkaufScanner.Services;
public class IpFilialeService
{
private readonly string _cs;
public IpFilialeService(IConfiguration cfg)
=> _cs = cfg.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default fehlt.");
public async Task<int?> TryMatchFilialeAsync(IPAddress clientIp)
{
if (clientIp == null) return null;
await using var con = new SqlConnection(_cs);
await con.OpenAsync();
const string sql = @"
SELECT FilialId, IpStart, IpEnd
FROM [KassensystemTest].[KS].[FilialIpRange] WITH (NOLOCK);";
var ranges = new List<(int FilialId, IPAddress Start, IPAddress End)>();
await using (var cmd = new SqlCommand(sql, con))
await using (var r = await cmd.ExecuteReaderAsync())
{
while (await r.ReadAsync())
{
var fid = r.GetInt32(0);
if (IPAddress.TryParse(r.GetString(1), out var start) &&
IPAddress.TryParse(r.GetString(2), out var end))
{
ranges.Add((fid, start, end));
}
}
}
foreach (var rg in ranges)
{
if (IsInRange(clientIp, rg.Start, rg.End))
return rg.FilialId;
}
return null;
}
private static bool IsInRange(IPAddress ip, IPAddress start, IPAddress end)
{
// nur IPv4: in Bytes vergleichen
var ipBytes = ip.GetAddressBytes();
var startBytes = start.GetAddressBytes();
var endBytes = end.GetAddressBytes();
if (ipBytes.Length != startBytes.Length || ipBytes.Length != endBytes.Length)
return false; // IPv4/IPv6 mismatch
bool gteStart = true, lteEnd = true;
for (int i = 0; i < ipBytes.Length; i++)
{
if (ipBytes[i] < startBytes[i]) { gteStart = false; break; }
if (ipBytes[i] > startBytes[i]) { break; }
}
for (int i = 0; i < ipBytes.Length; i++)
{
if (ipBytes[i] > endBytes[i]) { lteEnd = false; break; }
if (ipBytes[i] < endBytes[i]) { break; }
}
return gteStart && lteEnd;
}
}

View File

@ -0,0 +1,97 @@
using System.Data;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace WerksverkaufScanner.Services;
public class PreisAenderungSqlService
{
private readonly string _connectionString;
public PreisAenderungSqlService(IConfiguration cfg)
{
_connectionString = cfg.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' fehlt.");
}
public async Task<int> SaveAsync(
string filialId,
int artikelId,
int artikelVariantenId,
int variante,
int preisInCents,
string benutzer)
{
// Preis aus Cent zu Euro (decimal)
var neuerPreis = preisInCents / 100m;
await using var con = new SqlConnection(_connectionString);
await con.OpenAsync();
await using var tx = await con.BeginTransactionAsync();
try
{
// 1⃣ Alten Preis auslesen
decimal alterPreis = 0;
const string selectSql = @"
SELECT ISNULL(VerkaufsPreis, 0)
FROM [Kassensystem].[KS].[ArtikelVariantenFI]
WHERE FilialId = @FilialId AND ArtikelVariantenId = @ArtikelVariantenId;";
await using (var selectCmd = new SqlCommand(selectSql, con, (SqlTransaction)tx))
{
selectCmd.Parameters.Add("@FilialId", SqlDbType.NVarChar, 20).Value = filialId;
selectCmd.Parameters.Add("@ArtikelVariantenId", SqlDbType.Int).Value = artikelVariantenId;
var result = await selectCmd.ExecuteScalarAsync();
if (result != null && result != DBNull.Value)
alterPreis = Convert.ToDecimal(result);
}
// 2⃣ Update im ArtikelVariantenFI
const string updateSql = @"
UPDATE [Kassensystem].[KS].[ArtikelVariantenFI]
SET VerkaufsPreis = @NeuerPreis,
LetzteÄnderung = SYSUTCDATETIME(),
VonUser = @User
WHERE FilialId = @FilialId AND ArtikelVariantenId = @ArtikelVariantenId;";
await using (var updateCmd = new SqlCommand(updateSql, con, (SqlTransaction)tx))
{
updateCmd.Parameters.Add("@NeuerPreis", SqlDbType.Decimal).Value = neuerPreis;
updateCmd.Parameters.Add("@User", SqlDbType.NVarChar, 100).Value = benutzer;
updateCmd.Parameters.Add("@FilialId", SqlDbType.NVarChar, 20).Value = filialId;
updateCmd.Parameters.Add("@ArtikelVariantenId", SqlDbType.Int).Value = artikelVariantenId;
await updateCmd.ExecuteNonQueryAsync();
}
// 3⃣ Insert in PreisHistorie
const string insertSql = @"
INSERT INTO [Kassensystem].[KS].[PreisHistorie]
(FilialId, ArtikelVariantenId, AlterPreis, NeuerPreis, Zeitpunkt, Anwender)
VALUES
(@FilialId, @ArtikelVariantenId, @AlterPreis, @NeuerPreis, SYSUTCDATETIME(), @User);";
await using (var insertCmd = new SqlCommand(insertSql, con, (SqlTransaction)tx))
{
insertCmd.Parameters.Add("@FilialId", SqlDbType.NVarChar, 20).Value = filialId;
insertCmd.Parameters.Add("@ArtikelVariantenId", SqlDbType.Int).Value = artikelVariantenId;
insertCmd.Parameters.Add("@AlterPreis", SqlDbType.Decimal).Value = alterPreis;
insertCmd.Parameters.Add("@NeuerPreis", SqlDbType.Decimal).Value = neuerPreis;
insertCmd.Parameters.Add("@User", SqlDbType.NVarChar, 100).Value = benutzer;
await insertCmd.ExecuteNonQueryAsync();
}
await tx.CommitAsync();
return 1; // Erfolg
}
catch
{
await tx.RollbackAsync();
throw;
}
}
}

View File

@ -5,9 +5,10 @@
@inject IJSRuntime JS @inject IJSRuntime JS
<div class="app-shell"> <div class="app-shell">
<!-- Topbar -->
<nav class="topbar navbar navbar-expand-md navbar-dark topbar-gradient"> <nav class="topbar navbar navbar-expand-md navbar-dark topbar-gradient">
<div class="container-fluid"> <div class="container-fluid">
<!-- Toggler: öffnet auf Mobil das Offcanvas #sideMenu --> <!-- Mobile: Offcanvas-Toggler -->
<button class="navbar-toggler d-inline-flex d-md-none" <button class="navbar-toggler d-inline-flex d-md-none"
type="button" type="button"
data-bs-toggle="offcanvas" data-bs-toggle="offcanvas"
@ -33,7 +34,7 @@
</nav> </nav>
<div class="body-row"> <div class="body-row">
<!-- DESKTOP-SIDEBAR (ab md sichtbar) --> <!-- Sidebar (Desktop) -->
<aside class="sidebar border-end d-none d-md-block"> <aside class="sidebar border-end d-none d-md-block">
<div class="p-3"> <div class="p-3">
<ul class="nav flex-column gap-1"> <ul class="nav flex-column gap-1">
@ -43,11 +44,17 @@
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link text-white" href="/inventur" Match="NavLinkMatch.Prefix">Inventur</NavLink> <NavLink class="nav-link text-white" href="/inventur" Match="NavLinkMatch.Prefix">Inventur</NavLink>
</li> </li>
<li class="nav-item">
<NavLink class="nav-link text-white" href="/stammdaten" Match="NavLinkMatch.Prefix">Stammdaten</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link text-white" href="/einstellungen" Match="NavLinkMatch.Prefix">Einstellungen</NavLink>
</li>
</ul> </ul>
</div> </div>
</aside> </aside>
<!-- MOBILE-OFFCANVAS (unter md als Overlay) --> <!-- Offcanvas (Mobil) -->
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<div id="sideMenu" <div id="sideMenu"
@ -69,6 +76,14 @@
<NavLink class="nav-link text-white" href="/inventur" Match="NavLinkMatch.Prefix" <NavLink class="nav-link text-white" href="/inventur" Match="NavLinkMatch.Prefix"
@onclick="CloseMenu">Inventur</NavLink> @onclick="CloseMenu">Inventur</NavLink>
</li> </li>
<li class="nav-item">
<NavLink class="nav-link text-white" href="/stammdaten" Match="NavLinkMatch.Prefix"
@onclick="CloseMenu">Stammdaten</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link text-white" href="/einstellungen" Match="NavLinkMatch.Prefix"
@onclick="CloseMenu">Einstellungen</NavLink>
</li>
</ul> </ul>
<div class="mt-auto pt-3 border-top border-light"> <div class="mt-auto pt-3 border-top border-light">
@ -82,7 +97,7 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
<!-- Content -->
<main class="content d-flex flex-column"> <main class="content d-flex flex-column">
<div class="flex-grow-1 container-fluid p-3 p-md-4"> <div class="flex-grow-1 container-fluid p-3 p-md-4">
@Body @Body
@ -97,26 +112,55 @@
@code { @code {
private async Task CloseMenu() private async Task CloseMenu()
{ {
// Offcanvas schließen, Navigation des NavLink bleibt erhalten // Offcanvas schließen (NavLink-Navigation bleibt erhalten)
await JS.InvokeVoidAsync("hideOffcanvas", "#sideMenu"); await JS.InvokeVoidAsync("hideOffcanvas", "#sideMenu");
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (!firstRender) return;
{
var authState = await AuthProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity is { IsAuthenticated: true }) var authState = await AuthProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity is { IsAuthenticated: true })
{
// 1) Benutzername in localStorage schreiben
var username = user.Identity?.Name ?? "(unbekannt)";
try
{ {
var username = user.Identity?.Name ?? "(unbekannt)";
await JS.InvokeVoidAsync("localStorage.setItem", "scannerpilot.user", username); await JS.InvokeVoidAsync("localStorage.setItem", "scannerpilot.user", username);
} }
else catch { /* no-op */ }
// 2) Falls der Login/Controller eine Filial-ID als Claim mitliefert,
// schreiben wir sie NUR dann in localStorage, wenn dort noch nichts steht.
// -> QR-Konfiguration hat immer Vorrang!
var filialClaim = user.FindFirst("filialId")?.Value
?? user.FindFirst("filiale")?.Value; // fallback, falls anders benannt
if (!string.IsNullOrWhiteSpace(filialClaim))
{
try
{
var existingFil = await JS.InvokeAsync<string?>("localStorage.getItem", "scannerpilot.filialId");
if (string.IsNullOrWhiteSpace(existingFil))
{
await JS.InvokeVoidAsync("localStorage.setItem", "scannerpilot.filialId", filialClaim);
}
}
catch { /* no-op */ }
}
}
else
{
// Abgemeldet: lokale Infos säubern (nur user hier; Filial/Inventur bleiben
// bewusst bestehen, damit jemand sich neu anmelden kann ohne QR zu verlieren)
try
{ {
await JS.InvokeVoidAsync("localStorage.removeItem", "scannerpilot.user"); await JS.InvokeVoidAsync("localStorage.removeItem", "scannerpilot.user");
} }
catch { /* no-op */ }
} }
} }
} }