Files
ReifenSpaeth/Lagerbestand/Components/Pages/Customers.razor
T

719 lines
29 KiB
Plaintext

@page "/customers"
@implements IAsyncDisposable
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject ILexwareSyncService LexwareSyncService
@inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession
@inject IJSRuntime JS
@using Lagerbestand.Components.Shared
<PageTitle>Kundenverwaltung - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Kundenverwaltung - Reifen Späth Lagerverwaltung"
Description="Verwaltung der Kundendaten in der Reifen Späth Lagerverwaltung."
Robots="noindex, nofollow" />
@if (!UserSession.IsAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Administratoren zugänglich.</p>
<a href="/tire-storage" class="btn btn-primary">Zur Lagerübersicht</a>
</section>
}
else
{
<div class="customers-page">
<header class="page-header">
<h1>Kundenverwaltung</h1>
<div class="header-actions">
@if (lexwareEnabled)
{
<button class="btn btn-secondary" @onclick="SyncFromLexware" disabled="@isSyncing" aria-label="Kunden von Lexware synchronisieren">
@if (isSyncing)
{
<span class="spinner-inline" aria-hidden="true"></span>
}
else
{
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
}
Lexware Sync
</button>
}
<button class="btn btn-primary" @onclick="ShowAddCustomer" aria-label="Neuen Kunden anlegen">
<span aria-hidden="true">+</span> Neuer Kunde
</button>
</div>
</header>
@if (!string.IsNullOrEmpty(syncMessage))
{
<div class="sync-message @syncMessageType" role="status">@syncMessage</div>
}
@* Search *@
<section class="search-bar" aria-label="Kundensuche">
<label for="customer-search" class="visually-hidden">Kunden suchen</label>
<input type="search"
id="customer-search"
class="search-input"
placeholder="Name, Fahrzeug oder Kundennummer..."
@bind="searchTerm"
@bind:event="oninput"
@onkeyup="SearchCustomers" />
</section>
@* Customer List *@
@if (isLoading)
{
<div class="loading-state" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<span>Lade Kunden...</span>
</div>
}
else if (filteredCustomers.Count == 0)
{
<section class="empty-state" aria-labelledby="empty-title">
<div class="empty-icon" aria-hidden="true">👥</div>
<h2 id="empty-title">Keine Kunden</h2>
<p>@(string.IsNullOrEmpty(searchTerm) ? "Noch keine Kunden angelegt." : "Keine Ergebnisse für Ihre Suche.")</p>
</section>
}
else
{
<section class="customer-list" aria-label="Kundenliste" role="list">
@foreach (var customer in filteredCustomers)
{
<article @key="customer.Id" class="customer-card @(expandedId == customer.Id ? "expanded" : "")"
@onclick="() => ToggleCustomer(customer.Id)"
role="listitem"
aria-expanded="@(expandedId == customer.Id)">
<div class="customer-main">
<div class="customer-avatar" aria-hidden="true">
@(customer.FirstName?.Length > 0 ? customer.FirstName[0] : '?')@(customer.LastName.Length > 0 ? customer.LastName[0] : "")
</div>
<div class="customer-info">
<span class="customer-name">@customer.FullName</span>
@if (!string.IsNullOrEmpty(customer.Company))
{
<span class="customer-company">@customer.Company</span>
}
<span class="customer-number">@customer.CustomerNumber</span>
</div>
<div class="customer-meta">
@{
var activeVehicles = customer.Vehicles?.Where(v => v.IsActive).ToList() ?? new List<Vehicle>();
}
@if (activeVehicles.Any())
{
<span class="vehicle-make">@string.Join(", ", activeVehicles.Select(v => v.ShortName))</span>
}
<span class="tire-count">
@(customer.TireStacks?.Count(ts => ts.Status == TireStackStatus.Stored) ?? 0) Stapel
</span>
</div>
</div>
@if (expandedId == customer.Id)
{
<div class="customer-details" @onclick:stopPropagation="true">
<dl class="detail-grid">
@if (!string.IsNullOrEmpty(customer.Phone))
{
<div class="detail-item">
<dt class="label">Telefon</dt>
<dd><a href="tel:@customer.Phone" class="value link">@customer.Phone</a></dd>
</div>
}
@if (!string.IsNullOrEmpty(customer.Email))
{
<div class="detail-item">
<dt class="label">E-Mail</dt>
<dd><a href="mailto:@customer.Email" class="value link">@customer.Email</a></dd>
</div>
}
@if (customer.PrimaryVehicle is { } primaryVehicle)
{
<div class="detail-item">
<dt class="label">Fahrzeug</dt>
<dd class="value">@primaryVehicle.DisplayName</dd>
</div>
}
<div class="detail-item">
<dt class="label">Kunde seit</dt>
<dd class="value"><time datetime="@customer.CreatedAt.ToString("yyyy-MM-dd")">@customer.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy")</time></dd>
</div>
</dl>
@if (!string.IsNullOrEmpty(customer.Notes))
{
<div class="notes-section">
<span class="label">Notizen</span>
<span class="notes-text">@customer.Notes</span>
</div>
}
@* Customer's Tire Stacks *@
@if (customerTireStacks.TryGetValue(customer.Id, out var tireStacks) && tireStacks.Count > 0)
{
<section class="tireset-section" aria-labelledby="tireset-heading-@customer.Id">
<h3 id="tireset-heading-@customer.Id">Eingelagerte Reifenstapel</h3>
<ul>
@foreach (var ts in tireStacks.Where(t => t.Status == TireStackStatus.Stored))
{
<li @key="ts.Id" class="mini-tireset">
<span class="season" aria-label="@(ts.Season == TireSeason.Sommer ? "Sommerreifen" : ts.Season == TireSeason.Winter ? "Winterreifen" : "Ganzjahresreifen")">
@(ts.Season == TireSeason.Sommer ? "☀️" : ts.Season == TireSeason.Winter ? "❄️" : "🔄")
</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>
</section>
}
<nav class="customer-actions" aria-label="Kundenaktionen">
<button class="btn btn-sm btn-outline" @onclick="() => EditCustomer(customer)">
Bearbeiten
</button>
<a href="/tire-checkin" class="btn btn-sm btn-primary">
Reifenstapel einlagern
</a>
</nav>
</div>
}
</article>
}
</section>
}
</div>
@* Add/Edit Customer Modal *@
@if (showModal)
{
<div class="modal-backdrop" @onclick="CloseModal"></div>
<div class="app-modal" @onclick="CloseModal" role="dialog" aria-modal="true">
<div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(isEditing ? "Kunde bearbeiten" : "Neuer Kunde")</h5>
<button type="button" class="btn-close" @onclick="CloseModal" aria-label="Dialog schließen"></button>
</div>
<form @onsubmit="SaveCustomer" @onsubmit:preventDefault>
<div class="modal-body">
<div class="form-row-2col">
<div class="mb-3">
<label class="form-label">Vorname</label>
<input type="text" class="form-control" @bind="editCustomer.FirstName" />
</div>
<div class="mb-3">
<label class="form-label">Nachname <span class="required">*</span></label>
<input type="text" class="form-control" @bind="editCustomer.LastName" required />
</div>
</div>
<div class="mb-3">
<label class="form-label">Firma</label>
<input type="text" class="form-control" @bind="editCustomer.Company" />
</div>
<div class="form-row-2col">
<div class="mb-3">
<label class="form-label">Telefon</label>
<input type="tel" class="form-control" @bind="editCustomer.Phone" autocomplete="tel" />
</div>
<div class="mb-3">
<label class="form-label">E-Mail</label>
<input type="email" class="form-control" @bind="editCustomer.Email" autocomplete="email" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Notizen</label>
<textarea class="form-control" rows="2" @bind="editCustomer.Notes"></textarea>
</div>
@* Vehicle Management Section *@
<div class="vehicles-section">
<div class="vehicles-header">
<h6>Fahrzeuge</h6>
<button type="button" class="btn btn-sm btn-outline" @onclick="AddEditVehicle">
+ Fahrzeug hinzufügen
</button>
</div>
@if (editVehicles.Count == 0)
{
<p class="text-muted small">Keine Fahrzeuge hinterlegt</p>
}
else
{
<div class="vehicle-edit-list">
@foreach (var vehicle in editVehicles)
{
<div class="vehicle-edit-item @(vehicle.IsPrimary ? "primary" : "")">
<div class="vehicle-edit-info">
<span class="vehicle-make">@vehicle.Make @vehicle.Model</span>
@if (!string.IsNullOrEmpty(vehicle.TireSize))
{
<span class="vehicle-tiresize">@vehicle.TireSize</span>
}
@if (vehicle.IsPrimary)
{
<span class="primary-badge">Hauptfahrzeug</span>
}
</div>
<div class="vehicle-edit-actions">
@if (!vehicle.IsPrimary && editVehicles.Count > 1)
{
<button type="button" class="btn-icon" title="Als Hauptfahrzeug" @onclick="() => SetPrimaryVehicle(vehicle)">⭐</button>
}
<button type="button" class="btn-icon" title="Bearbeiten" @onclick="() => EditVehicle(vehicle)">✏️</button>
<button type="button" class="btn-icon danger" title="Entfernen" @onclick="() => RemoveVehicle(vehicle)">🗑️</button>
</div>
</div>
}
</div>
}
@if (showVehicleForm)
{
<div class="vehicle-form">
<div class="form-row-2col">
<div class="mb-2">
<label class="form-label small">Marke *</label>
<input type="text" class="form-control form-control-sm" @bind="editVehicle.Make" placeholder="z.B. BMW" />
</div>
<div class="mb-2">
<label class="form-label small">Modell</label>
<input type="text" class="form-control form-control-sm" @bind="editVehicle.Model" />
</div>
</div>
<div class="form-row-2col">
<div class="mb-2">
<label class="form-label small">Reifengröße *</label>
<input type="text" class="form-control form-control-sm" @bind="editVehicle.TireSize" placeholder="z.B. 225/45 R17" />
</div>
</div>
<div class="vehicle-form-actions">
<button type="button" class="btn btn-sm btn-outline" @onclick="CancelVehicleForm">Abbrechen</button>
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveVehicle" disabled="@(string.IsNullOrWhiteSpace(editVehicle.Make) || string.IsNullOrWhiteSpace(editVehicle.TireSize))">
@(isEditingVehicle ? "Speichern" : "Hinzufügen")
</button>
</div>
</div>
}
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @onclick="CloseModal">Abbrechen</button>
<button type="submit" class="btn btn-primary" disabled="@(!CanSave)">
@(isEditing ? "Speichern" : "Anlegen")
</button>
</div>
</form>
</div>
</div>
</div>
}
}
@code {
private List<Customer> allCustomers = new();
private List<Customer> filteredCustomers = new();
private Dictionary<Guid, List<TireStack>> customerTireStacks = new();
private string searchTerm = "";
private Guid? expandedId;
private bool isLoading = true;
// Modal state
private bool showModal;
private bool isEditing;
private Customer editCustomer = new();
private string? errorMessage;
// Vehicle editing state
private List<Vehicle> editVehicles = new();
private Vehicle editVehicle = new();
private bool showVehicleForm;
private bool isEditingVehicle;
private List<Guid> vehiclesToDelete = new();
// Lexware sync state
private bool lexwareEnabled;
private bool isSyncing;
private string? syncMessage;
private string syncMessageType = "success";
private bool CanSave =>
!string.IsNullOrWhiteSpace(editCustomer.LastName);
protected override async Task OnInitializedAsync()
{
if (UserSession.IsAdmin)
{
await LoadCustomers();
await CheckLexwareEnabled();
}
}
private DotNetObjectReference<Customers>? _visibilityRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsAdmin)
{
_visibilityRef = DotNetObjectReference.Create(this);
await VisibilityRefresh.RegisterAsync(JS, _visibilityRef, nameof(OnVisibilityReturn), 120);
}
}
[JSInvokable]
public async Task OnVisibilityReturn(int hiddenSeconds)
{
if (!UserSession.IsAdmin) return;
await LoadCustomers();
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
await VisibilityRefresh.UnregisterAsync(JS);
_visibilityRef?.Dispose();
}
private async Task CheckLexwareEnabled()
{
try
{
var settings = await SiteSettingsService.GetLexwareApiSettingsAsync();
lexwareEnabled = settings.IsEnabled && settings.IsConfigured;
}
catch { }
}
private async Task SyncFromLexware()
{
isSyncing = true;
syncMessage = null;
StateHasChanged();
try
{
var result = await LexwareSyncService.SyncCustomersAsync();
syncMessage = result.Message;
syncMessageType = result.Success ? "success" : "error";
if (result.Success)
{
await LoadCustomers();
}
}
catch (Exception ex)
{
syncMessage = $"Fehler: {ex.Message}";
syncMessageType = "error";
}
finally
{
isSyncing = false;
}
}
private async Task LoadCustomers()
{
isLoading = true;
StateHasChanged();
try
{
allCustomers = await CustomerService.GetAllAsync();
filteredCustomers = allCustomers;
}
finally
{
isLoading = false;
}
}
private void SearchCustomers()
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
filteredCustomers = allCustomers;
return;
}
var term = searchTerm.ToLower();
filteredCustomers = allCustomers
.Where(c =>
c.FullName.ToLower().Contains(term) ||
(c.Company?.ToLower().Contains(term) ?? false) ||
c.Vehicles.Any(v => v.Make.ToLower().Contains(term) || (v.Model?.ToLower().Contains(term) ?? false)) ||
c.CustomerNumber.ToLower().Contains(term) ||
(c.Phone?.Contains(term) ?? false))
.ToList();
}
private async Task ToggleCustomer(Guid id)
{
if (expandedId == id)
{
expandedId = null;
return;
}
expandedId = id;
// Load tire stacks for this customer if not already loaded
if (!customerTireStacks.ContainsKey(id))
{
var tireStacks = await TireStackService.GetByCustomerAsync(id);
customerTireStacks[id] = tireStacks;
}
}
private void ShowAddCustomer()
{
editCustomer = new Customer();
editVehicles = new List<Vehicle>();
vehiclesToDelete = new List<Guid>();
isEditing = false;
errorMessage = null;
showVehicleForm = false;
showModal = true;
}
private async Task EditCustomer(Customer customer)
{
editCustomer = new Customer
{
Id = customer.Id,
CustomerNumber = customer.CustomerNumber,
FirstName = customer.FirstName,
LastName = customer.LastName,
Company = customer.Company,
Phone = customer.Phone,
Email = customer.Email,
Notes = customer.Notes
};
// Load vehicles for editing
var vehicles = await CustomerService.GetVehiclesAsync(customer.Id);
editVehicles = vehicles.Select(v => new Vehicle
{
Id = v.Id,
CustomerId = v.CustomerId,
Make = v.Make,
Model = v.Model,
TireSize = v.TireSize,
IsPrimary = v.IsPrimary,
IsActive = v.IsActive,
Notes = v.Notes
}).ToList();
vehiclesToDelete = new List<Guid>();
isEditing = true;
errorMessage = null;
showVehicleForm = false;
showModal = true;
}
private void CloseModal()
{
showModal = false;
editCustomer = new Customer();
editVehicles = new List<Vehicle>();
vehiclesToDelete = new List<Guid>();
showVehicleForm = false;
errorMessage = null;
}
// Vehicle management methods
private void AddEditVehicle()
{
editVehicle = new Vehicle();
isEditingVehicle = false;
showVehicleForm = true;
}
private void EditVehicle(Vehicle vehicle)
{
editVehicle = new Vehicle
{
Id = vehicle.Id,
CustomerId = vehicle.CustomerId,
Make = vehicle.Make,
Model = vehicle.Model,
TireSize = vehicle.TireSize,
IsPrimary = vehicle.IsPrimary,
IsActive = vehicle.IsActive,
Notes = vehicle.Notes
};
isEditingVehicle = true;
showVehicleForm = true;
}
private void CancelVehicleForm()
{
editVehicle = new Vehicle();
showVehicleForm = false;
isEditingVehicle = false;
}
private void SaveVehicle()
{
if (string.IsNullOrWhiteSpace(editVehicle.Make) || string.IsNullOrWhiteSpace(editVehicle.TireSize)) return;
if (isEditingVehicle)
{
var existing = editVehicles.FirstOrDefault(v => v.Id == editVehicle.Id);
if (existing != null)
{
existing.Make = editVehicle.Make;
existing.Model = editVehicle.Model;
existing.TireSize = editVehicle.TireSize;
existing.Notes = editVehicle.Notes;
}
}
else
{
var newVehicle = new Vehicle
{
Id = Guid.NewGuid(),
Make = editVehicle.Make,
Model = editVehicle.Model,
TireSize = editVehicle.TireSize,
IsPrimary = editVehicles.Count == 0, // First vehicle is primary
IsActive = true
};
editVehicles.Add(newVehicle);
}
showVehicleForm = false;
editVehicle = new Vehicle();
isEditingVehicle = false;
}
private void RemoveVehicle(Vehicle vehicle)
{
editVehicles.Remove(vehicle);
// Track for deletion if it's an existing vehicle
if (vehicle.CustomerId != Guid.Empty)
{
vehiclesToDelete.Add(vehicle.Id);
}
// Ensure there's still a primary if vehicles remain
if (editVehicles.Any() && !editVehicles.Any(v => v.IsPrimary))
{
editVehicles.First().IsPrimary = true;
}
}
private void SetPrimaryVehicle(Vehicle vehicle)
{
foreach (var v in editVehicles)
{
v.IsPrimary = v.Id == vehicle.Id;
}
}
private async Task SaveCustomer()
{
if (!CanSave) return;
try
{
if (isEditing)
{
var existing = allCustomers.First(c => c.Id == editCustomer.Id);
existing.FirstName = editCustomer.FirstName;
existing.LastName = editCustomer.LastName;
existing.Company = editCustomer.Company;
existing.Phone = editCustomer.Phone;
existing.Email = editCustomer.Email;
existing.Notes = editCustomer.Notes;
await CustomerService.UpdateAsync(existing);
// Delete removed vehicles
foreach (var vehicleId in vehiclesToDelete)
{
await CustomerService.DeleteVehicleAsync(vehicleId);
}
// Update or add vehicles
foreach (var vehicle in editVehicles)
{
if (vehicle.CustomerId == Guid.Empty)
{
// New vehicle
vehicle.CustomerId = existing.Id;
await CustomerService.AddVehicleAsync(existing.Id, vehicle);
}
else
{
// Existing vehicle
await CustomerService.UpdateVehicleAsync(vehicle);
}
}
// Handle primary vehicle
var primary = editVehicles.FirstOrDefault(v => v.IsPrimary);
if (primary != null)
{
await CustomerService.SetPrimaryVehicleAsync(existing.Id, primary.Id);
}
// Reload to get updated vehicles
await LoadCustomers();
}
else
{
// Create new customer
if (editVehicles.Any())
{
var firstVehicle = editVehicles.First();
var created = await CustomerService.CreateWithVehicleAsync(editCustomer, firstVehicle);
// Add remaining vehicles
for (int i = 1; i < editVehicles.Count; i++)
{
await CustomerService.AddVehicleAsync(created.Id, editVehicles[i]);
}
allCustomers.Insert(0, created);
}
else
{
var created = await CustomerService.CreateAsync(editCustomer);
allCustomers.Insert(0, created);
}
}
SearchCustomers();
CloseModal();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
}