Addet VateRate to produktPage

This commit is contained in:
2026-08-27 10:13:08 +02:00
parent 048f45a4a1
commit 8de6ff9338
13 changed files with 356 additions and 6 deletions

View File

@@ -21,17 +21,34 @@ namespace Pos.Service
return _productGroupRepository.Any();
}
public void AddProductGroup(string name, decimal vatRate)
{
_productGroupRepository.Add(
ValidateName(name),
ValidateVatRate(vatRate));
CacheService.Invalidate();
}
public void AddProductGroup(string name)
{
_productGroupRepository.Add(ValidateName(name));
CacheService.Invalidate();
AddProductGroup(name, 25m);
}
public void RenameProductGroup(int productGroupId, string name)
{
ProductGroupEntity productGroup = _productGroupRepository.Get(productGroupId);
UpdateProductGroup(productGroupId, name, productGroup.VatRate);
}
public void UpdateProductGroup(
int productGroupId,
string name,
decimal vatRate)
{
ValidateId(productGroupId);
_productGroupRepository.Edit(
ValidateName(name),
ValidateVatRate(vatRate),
productGroupId);
CacheService.Invalidate();
}
@@ -66,6 +83,16 @@ namespace Pos.Service
throw new ArgumentOutOfRangeException(nameof(productGroupId));
}
private static decimal ValidateVatRate(decimal vatRate)
{
if (vatRate < 0 || vatRate > 100)
throw new ArgumentOutOfRangeException(
nameof(vatRate),
"Momsen skal være mellem 0 og 100 procent.");
return decimal.Round(vatRate, 2);
}
private static void ValidateOrder(IReadOnlyList<int> orderedIds)
{
if (orderedIds == null)

View File

@@ -7,6 +7,10 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.18" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.18" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.18">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.10" />

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Database.Migrations
{
[DbContext(typeof(PosDbContext))]
[Migration("20260827000000_AddProductGroupVatRate")]
public class AddProductGroupVatRate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "VatRate",
table: "productgroup",
type: "decimal(5,2)",
nullable: false,
defaultValue: 25m);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "VatRate",
table: "productgroup");
}
}
}

View File

@@ -16,6 +16,7 @@ namespace Database.Models
public string Name { get; set; }
public bool IsArchived { get; set; }
public int Index { get; set; }
public decimal VatRate { get; set; }
public ICollection<ProductEntity> Products { get; set; }
}

View File

@@ -28,10 +28,11 @@ namespace Database.Repository
return product;
}
public void Add(string name)
public void Add(string name, decimal vatRate)
{
ProductGroupEntity productGroup = new ProductGroupEntity();
productGroup.Name = name;
productGroup.VatRate = vatRate;
using PosDbContext context = new PosDbContext();
//Get the highest index
ProductGroupEntity highest = context.ProductGroups.OrderByDescending(c => c.Index).Take(1).FirstOrDefault();
@@ -45,11 +46,12 @@ namespace Database.Repository
context.SaveChanges();
}
public void Edit(string name, int id)
public void Edit(string name, decimal vatRate, int id)
{
using PosDbContext context = new PosDbContext();
ProductGroupEntity productGroup = context.ProductGroups.Single(c => c.Id == id);
productGroup.Name = name;
productGroup.VatRate = vatRate;
context.SaveChanges();
}

View File

@@ -0,0 +1,30 @@
@using Database.Models
@using Pos.Service
@inject ProductGroupService ProductGroupService
@if (IsOpen)
{
<div class="dialog-overlay">
<div class="product-dialog" role="dialog" aria-modal="true" aria-labelledby="product-dialog-title">
<div class="dialog-header">
<h2 id="product-dialog-title">@(_productGroupId.HasValue ? "Redigér vare" : "Opret vare")</h2>
<button class="close-button" type="button" aria-label="Luk" @onclick="CloseAsync">&times;</button>
</div>
<div class="dialog-content">
<label for="product-name">Navn</label>
<input id="product-name" class="dialog-input" type="text" placeholder="Indtast navn"
@bind="_name" @bind:event="oninput" />
<label for="product-vat">Moms i procent</label>
<input id="product-vat" class="dialog-input" type="number" min="0" max="100" step="0.01"
@bind="_vatRate" />
</div>
<div class="dialog-actions">
<button class="cancel-button" type="button" @onclick="CloseAsync">Annuller</button>
<button class="save-button" type="button" disabled="@(!CanSave)" @onclick="SaveAsync">
@(_productGroupId.HasValue ? "Gem ændringer" : "Opret vare")
</button>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,50 @@
using Database.Models;
using Microsoft.AspNetCore.Components;
namespace Pos.Web.Components.Pages.Settings
{
public partial class ProductGroupDialog
{
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public ProductGroupEntity? ProductGroup { get; set; }
[Parameter] public EventCallback ProductGroupSaved { get; set; }
private int? _productGroupId;
private string _name = string.Empty;
private decimal _vatRate = 25m;
private bool _wasOpen;
private bool CanSave => !string.IsNullOrWhiteSpace(_name) && _vatRate >= 0 && _vatRate <= 100;
protected override void OnParametersSet()
{
if (IsOpen && !_wasOpen)
{
_productGroupId = ProductGroup?.Id;
_name = ProductGroup?.Name ?? string.Empty;
_vatRate = ProductGroup?.VatRate ?? 25m;
}
_wasOpen = IsOpen;
}
private async Task SaveAsync()
{
if (!CanSave)
return;
if (_productGroupId.HasValue)
ProductGroupService.UpdateProductGroup(_productGroupId.Value, _name, _vatRate);
else
ProductGroupService.AddProductGroup(_name, _vatRate);
await ProductGroupSaved.InvokeAsync();
await CloseAsync();
}
private async Task CloseAsync()
{
_wasOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
}
}

View File

@@ -0,0 +1,14 @@
.dialog-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 23 42 / 45%); }
.product-dialog { width: min(100%, 650px); overflow: hidden; border-radius: 12px; background: white; color: #242424; color-scheme: light; }
.dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 28px 40px 18px; }
.dialog-header h2 { margin: 0; font-size: 28px; }
.close-button { border: 0; background: transparent; color: #334155; cursor: pointer; font-size: 36px; line-height: 1; }
.dialog-content { display: grid; gap: 10px; padding: 18px 40px 36px; }
.dialog-content label { margin-top: 8px; font-size: 16px; font-weight: 500; }
.dialog-input { box-sizing: border-box; width: 100%; height: 48px; padding: 0 14px; border: 1px solid #8a8a8a; border-radius: 6px; outline: none; background: #fff; color: #242424; font: inherit; }
.dialog-input:focus { border-color: #0f6cbd; box-shadow: 0 0 0 1px #0f6cbd; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 16px; padding: 24px 40px; border-top: 1px solid #e2e8f0; }
.cancel-button, .save-button { min-height: 42px; padding: 0 22px; border-radius: 6px; cursor: pointer; font: inherit; font-weight: 500; }
.cancel-button { border: 1px solid #616161; background: #fff; color: #242424; }
.save-button { border: 1px solid #0f6cbd; background: #0f6cbd; color: #fff; }
.save-button:disabled { border-color: #d1d1d1; background: #e0e0e0; color: #707070; cursor: not-allowed; }

View File

@@ -0,0 +1,77 @@
@page "/settings/products"
@rendermode InteractiveServer
@using Database.Models
@using Pos.Service
@inject IDialogService DialogService
@inject ProductGroupService ProductGroupService
<Header />
<div class="products-page">
<div class="products-header">
<h1>Vare</h1>
</div>
<div class="products-toolbar">
<label class="search-field">
<span aria-hidden="true">⌕</span>
<input type="search" placeholder="Søg vare" aria-label="Søg vare"
@bind="_searchText" @bind:event="oninput" />
</label>
<FluentButton Class="create-button" Appearance="@ButtonAppearance.Primary"
OnClick="OpenCreateDialog">
<span class="button-icon" aria-hidden="true"></span> Opret vare
</FluentButton>
<FluentButton Class="save-order-button" Appearance="@ButtonAppearance.Outline"
Disabled="@(!_orderHasChanged)" OnClick="SaveOrder">
<span class="button-icon" aria-hidden="true">⇅</span> Gem rækkefølge
</FluentButton>
</div>
@if (_isLoading)
{
<FluentSpinner />
}
else
{
<div class="products-table">
<div class="products-row products-column-header">
<span>Rækkefølge</span><span>Navn</span><span>Moms</span><span>Status</span><span>Handling</span>
</div>
<div class="products-list">
@foreach (ProductGroupEntity productGroup in FilteredProductGroups)
{
<div class="products-row product-row @GetDragClass(productGroup)" @key="productGroup.Id"
draggable="true" @ondragstart="@(() => StartDragging(productGroup))"
@ondragover:preventDefault="true" @ondrop="@(() => DropProductGroup(productGroup))"
@ondragend="StopDragging">
<span class="drag-handle" title="Træk for at ændre rækkefølge">⠿</span>
<span class="product-name">@productGroup.Name</span>
<span>@productGroup.VatRate.ToString("0.##") %</span>
<span class="active-status">Aktiv</span>
<span class="row-actions">
<FluentButton Appearance="@ButtonAppearance.Outline"
OnClick="@(() => OpenEditDialog(productGroup))">Redigér</FluentButton>
<FluentButton Class="delete-button" Appearance="@ButtonAppearance.Outline"
OnClick="@(() => ConfirmDeactivation(productGroup))">Slet</FluentButton>
</span>
</div>
}
@if (!FilteredProductGroups.Any())
{
<p class="empty-state">Ingen varer matcher din søgning.</p>
}
</div>
</div>
}
<p class="products-help"><span aria-hidden="true">ⓘ</span>
Træk i håndtaget for at ændre rækkefølgen. Deaktiverede varer vises ikke i kassesalget.
</p>
</div>
<ProductGroupDialog @bind-IsOpen="_showProductGroupDialog"
ProductGroup="_productGroupToEdit"
ProductGroupSaved="ProductGroupSaved" />

View File

@@ -0,0 +1,93 @@
using Database.Models;
using Microsoft.FluentUI.AspNetCore.Components;
namespace Pos.Web.Components.Pages.Settings
{
public partial class Products
{
private List<ProductGroupEntity> _productGroups = [];
private ProductGroupEntity? _draggedProductGroup;
private ProductGroupEntity? _productGroupToEdit;
private string _searchText = string.Empty;
private bool _isLoading = true;
private bool _showProductGroupDialog;
private bool _orderHasChanged;
private IEnumerable<ProductGroupEntity> FilteredProductGroups =>
_productGroups.Where(productGroup =>
string.IsNullOrWhiteSpace(_searchText) ||
productGroup.Name.Contains(_searchText, StringComparison.CurrentCultureIgnoreCase));
protected override void OnInitialized()
{
LoadProductGroups();
_isLoading = false;
}
private void LoadProductGroups() =>
_productGroups = ProductGroupService.GetProductGroups();
private void OpenCreateDialog()
{
_productGroupToEdit = null;
_showProductGroupDialog = true;
}
private void OpenEditDialog(ProductGroupEntity productGroup)
{
_productGroupToEdit = productGroup;
_showProductGroupDialog = true;
}
private void ProductGroupSaved()
{
LoadProductGroups();
_orderHasChanged = false;
}
private void StartDragging(ProductGroupEntity productGroup) =>
_draggedProductGroup = productGroup;
private void DropProductGroup(ProductGroupEntity target)
{
if (_draggedProductGroup == null || _draggedProductGroup.Id == target.Id ||
!string.IsNullOrWhiteSpace(_searchText))
{
StopDragging();
return;
}
int oldIndex = _productGroups.IndexOf(_draggedProductGroup);
int newIndex = _productGroups.IndexOf(target);
_productGroups.RemoveAt(oldIndex);
_productGroups.Insert(newIndex, _draggedProductGroup);
_orderHasChanged = true;
StopDragging();
}
private void StopDragging() => _draggedProductGroup = null;
private string GetDragClass(ProductGroupEntity productGroup) =>
_draggedProductGroup?.Id == productGroup.Id ? "dragging" : string.Empty;
private void SaveOrder()
{
ProductGroupService.UpdateOrder(_productGroups.Select(item => item.Id).ToList());
_orderHasChanged = false;
}
private async Task ConfirmDeactivation(ProductGroupEntity productGroup)
{
DialogResult result = await DialogService.ShowConfirmationAsync(
$"Er du sikker på, at du vil slette {productGroup.Name}?",
"Bekræft sletning", "Slet", "Annuller");
if (!result.Cancelled)
{
ProductGroupService.ArchiveProductGroup(productGroup.Id);
LoadProductGroups();
_orderHasChanged = false;
}
}
}
}

View File

@@ -0,0 +1,24 @@
.products-page { width: min(100%, 1400px); margin: 0 auto; padding: 32px; }
.products-header { padding-bottom: 20px; border-bottom: 1px solid #e2e8f0; }
.products-header h1 { margin: 0; }
.products-toolbar { display: grid; grid-template-columns: minmax(280px, 1.6fr) minmax(210px, .8fr) minmax(230px, .9fr); gap: 32px; margin: 32px 0; }
.search-field { display: flex; align-items: center; gap: 16px; box-sizing: border-box; height: 56px; padding: 0 20px; border: 1px solid #cbd5e1; border-radius: 8px; color: #475569; }
.search-field span { font-size: 30px; }
.search-field input { width: 100%; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 18px; }
.products-toolbar ::deep fluent-button { width: 100%; min-height: 56px; font-size: 18px; }
.button-icon { margin-right: 10px; font-size: 26px; }
.products-table { overflow: hidden; border: 1px solid #cbd5e1; border-radius: 10px; }
.products-list { max-height: 535px; overflow-y: auto; }
.products-row { display: grid; grid-template-columns: 150px minmax(190px, 1.3fr) 110px 120px minmax(250px, 1fr); align-items: center; min-height: 76px; padding: 0 32px; border-bottom: 1px solid #e2e8f0; }
.products-column-header { min-height: 56px; color: #334155; font-weight: 600; }
.product-row:last-child { border-bottom: 0; }
.product-row.dragging { opacity: .45; }
.drag-handle { width: fit-content; color: #64748b; cursor: grab; font-size: 30px; }
.product-name { font-size: 19px; }
.active-status { color: #16a34a; }
.row-actions { display: flex; gap: 12px; }
.products-page ::deep .delete-button { color: #d13438; border-color: #d13438; }
.products-help { display: flex; gap: 12px; margin: 32px 6px 0; color: #64748b; }
.products-help span { font-size: 21px; }
.empty-state { margin: 0; padding: 36px; color: #64748b; text-align: center; }
@media (max-width: 900px) { .products-toolbar { grid-template-columns: 1fr; gap: 12px; } .products-table { overflow-x: auto; } .products-row { min-width: 900px; } }

View File

@@ -10,7 +10,7 @@
<FluentButton Class="settings-button" onclick="@(() => Nav.NavigateTo("/settings/employees"))">
Medarbejder
</FluentButton>
<FluentButton Class="settings-button">
<FluentButton Class="settings-button" onclick="@(() => Nav.NavigateTo("/settings/products"))">
Vare
</FluentButton>
</div>

View File

@@ -1,4 +1,4 @@
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Pos.Service;
using Pos.Web.Components;
@@ -12,6 +12,7 @@ builder.Services.AddRazorComponents()
builder.Services.AddFluentUIComponents();
builder.Services.AddScoped<EmployeeService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<ProductGroupService>();
var app = builder.Build();