Addet VateRate to produktPage
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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">×</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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
77
PointOfSale/Pos.Web/Components/Pages/Settings/Products.razor
Normal file
77
PointOfSale/Pos.Web/Components/Pages/Settings/Products.razor
Normal 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" />
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user