Compare commits

...

6 Commits

41 changed files with 1171 additions and 65 deletions

35
PointOfSale/.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**
database-backup/
.env
database-data/

View File

@@ -360,3 +360,10 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# Local Docker database data and secrets
.env
database-backup/
database-data/

View File

@@ -1,15 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace Pos.Models
namespace Pos.Models;
public class TotalSaleDetail
{
public class TotalSaleDetail
{
public decimal TotalSale { get; set; }
public int TotalCustomer { get; set; }
public List<TotalSaleCategory> TotalSaleCategories { get; set; } = new();
}
public decimal TotalSale { get; set; }
public int TotalCustomer { get; set; }
public List<TotalSaleCategory> TotalSaleCategories { get; set; } = new();
public List<TotalSalePayment> PaymentBreakdown { get; set; } = new();
}

View File

@@ -0,0 +1,7 @@
namespace Pos.Models;
public class TotalSalePayment
{
public string PaymentMethod { get; set; } = string.Empty;
public decimal Amount { get; set; }
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using Database.Models;
namespace Pos.Service;
public interface IProductService
{
List<ProductEntity> GetProducts(int productGroupId);
ProductEntity GetProduct(int productId);
void AddProduct(string name, int productGroupId);
void UpdateProduct(int productId, int productGroupId, string name);
void ArchiveProduct(int productId);
void UpdateOrder(int productGroupId, IReadOnlyList<int> orderedProductIds);
}

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

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Database.Models;
@@ -93,3 +93,5 @@ namespace Pos.Service
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
@@ -103,6 +103,34 @@ namespace Pos.Service
foreach (SaleEntity sale in sales)
{
List<SaleLineEntity> saleLineBySaleId = _saleRepository.GetSaleLineBySaleId(sale.Id);
List<PaymentEntity> paymentsBySaleId =
_saleRepository.GetPaymentBySaleId(sale.Id);
foreach (PaymentEntity payment in paymentsBySaleId)
{
string paymentMethod = string.IsNullOrWhiteSpace(payment.Type)
? "Ukendt"
: payment.Type;
TotalSalePayment? paymentSummary =
totalSaleDetail.PaymentBreakdown
.FirstOrDefault(item =>
item.PaymentMethod == paymentMethod);
if (paymentSummary is null)
{
totalSaleDetail.PaymentBreakdown.Add(
new TotalSalePayment
{
PaymentMethod = paymentMethod,
Amount = payment.Amount
});
}
else
{
paymentSummary.Amount += payment.Amount;
}
}
foreach (SaleLineEntity saleLine in saleLineBySaleId)
{
@@ -303,3 +331,4 @@ namespace Pos.Service
}
}
}

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

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -58,3 +58,7 @@ namespace Pos
}
}
}

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

@@ -1,4 +1,4 @@
using Database.Models;
using Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@@ -32,9 +32,9 @@ namespace Database
{
LoadConfig l = new LoadConfig();
IConfiguration config = l.ByEnvironment();
string connectionString = config["SqlServer"].ToString();
string connectionString = System.Environment.GetEnvironmentVariable("SqlServer") ?? config["SqlServer"].ToString();
optionsBuilder
.UseSqlServer(connectionString)
.UseSqlServer(connectionString, sqlOptions => sqlOptions.EnableRetryOnFailure())
.UseLoggerFactory(LoggerFactory.Create(b => b
.AddFilter(level => level >= LogLevel.Information)))
.EnableSensitiveDataLogging()
@@ -43,3 +43,6 @@ namespace Database
}
}
}

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

@@ -9,7 +9,7 @@
<FluentButton Class="menu-button" Size="ButtonSize.Large">
Kassesalg
</FluentButton>
<FluentButton Class="menu-button" Size="ButtonSize.Large">
<FluentButton Class="menu-button" Size="ButtonSize.Large" onclick="@(() => Nav.NavigateTo("/todaySales"))">
Dagens salg
</FluentButton>
<FluentButton Class="menu-button" Size="ButtonSize.Large">
@@ -18,4 +18,4 @@
<FluentButton Class="menu-button" Size="ButtonSize.Large" onclick="@(() => Nav.NavigateTo("/settings"))">
Indstillinger
</FluentButton>
</div>
</div>

View File

@@ -1,3 +1,4 @@
.home-container {
width: min(100%, 1100px);
margin: 32px auto;
@@ -11,4 +12,12 @@
min-height: 220px;
font-size: 28px;
font-weight: 600;
}
}
@media (max-width: 680px) {
.home-container {
grid-template-columns: 1fr;
margin: 24px 16px;
}
}

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

@@ -5,16 +5,14 @@
<Header />
<div class="settings-page">
<h1>Indstillinger</h1>
<p>Administrer data, der bruges i kassesalget.</p>
<div class="settings-grid">
<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>
</div>
</div>

View File

@@ -1,3 +1,4 @@
.settings-page {
width: min(100%, 1100px);
margin: 0 auto;
@@ -22,3 +23,14 @@
font-size: 28px;
font-weight: 600;
}
@media (max-width: 680px) {
.settings-page {
padding: 24px 16px;
}
.settings-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,34 @@
@page "/todaySales"
@rendermode InteractiveServer
@inject IJSRuntime Js
@inject SaleService SaleService
<Header />
<div class="page-container today-sales-page">
<div class="filters">
<label class="filter-control"><span>Dato</span><span class="filter-box"><svg viewBox="0 0 24 24"><rect x="3.5" y="5.5" width="17" height="15" rx="2"/><path d="M7 3.5v4M17 3.5v4M3.5 9.5h17"/></svg><input type="date" value="@_date.ToString("yyyy-MM-dd")" @onchange="DateChanged"/></span></label>
<label class="filter-control short"><span>Periode</span><span class="filter-box"><svg viewBox="0 0 24 24"><rect x="3.5" y="5.5" width="17" height="15" rx="2"/><path d="M7 3.5v4M17 3.5v4M3.5 9.5h17"/></svg><select @onchange="RangeChanged" value="@_range"><option value="1">1 dag</option><option value="7">7 dage</option><option value="30">30 dage</option></select></span></label>
</div>
<section class="ui-card summary-card">
<div class="summary-icon"><svg viewBox="0 0 64 64"><path d="M12 52h40M18 48V35h8v13M29 48V25h8v23M40 48V14h8v34"/></svg></div>
<div><p>Dagens salg</p><strong>@Currency(_summary.Total)</strong><small>@_summary.SaleCount salg</small></div>
</section>
<div class="sales-grid">
<UiCard Title="Salg pr. varegruppe" Class="sales-category-card">
<MetricList TItem="Metric" Items="@_summary.Categories">
<HeaderContent><span>Varegruppe</span><span>Salg</span></HeaderContent>
<RowTemplate Context="row"><div class="metric-row"><span>@row.Name</span><span>@Currency(row.Amount)</span></div></RowTemplate>
</MetricList>
</UiCard>
<div class="sales-right">
<UiCard Title="Betalingsfordeling">
<MetricList TItem="Metric" Items="@_summary.Payments">
<RowTemplate Context="row"><div class="metric-row"><span>@row.Name</span><span>@Currency(row.Amount)</span></div></RowTemplate>
</MetricList>
</UiCard>
<button class="print-button" type="button" @onclick="PrintAsync"><svg viewBox="0 0 24 24"><path d="M6 9V3h12v6M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 14h12v7H6z"/></svg>Print salg</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Pos.Models;
using System.Globalization;
namespace Pos.Web.Components.Pages.TodaySales;
public partial class TodaySales
{
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private TotalSaleDetail? _totalSaleDetail;
private DateTime _date = DateTime.Now;
private string _range = "1";
private Summary _summary = new(0m, 0, Array.Empty<Metric>(), Array.Empty<Metric>());
private async Task DateChanged(ChangeEventArgs e)
{
if (DateTime.TryParse(e.Value?.ToString(), out var date))
{
_date = date;
await LoadAsync();
}
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task RangeChanged(ChangeEventArgs e)
{
_range = e.Value?.ToString() ?? "1";
await LoadAsync();
}
private Task LoadAsync()
{
var result = SaleService.TotalSale(_date);
_totalSaleDetail = result;
_summary = new Summary(
result.TotalSale,
result.TotalCustomer,
result.TotalSaleCategories
.Select(category => new Metric(category.Category, category.Sale))
.ToList(),
result.PaymentBreakdown
.Select(payment => new Metric(payment.PaymentMethod, payment.Amount))
.ToList());
return Task.CompletedTask;
}
private async Task PrintAsync()
{
// TODO: Kobl til SaleService.PrintSaleOfDay(_date, _totalSaleDetail)
// eller POST /api/sales/print.
await Js.InvokeVoidAsync("window.print");
}
private static string Currency(decimal amount) =>
$"{amount.ToString("N2", Danish)} kr.";
private sealed record Metric(string Name, decimal Amount);
private sealed record Summary(
decimal Total,
int SaleCount,
IReadOnlyList<Metric> Categories,
IReadOnlyList<Metric> Payments);
}

View File

@@ -0,0 +1,307 @@
.today-sales-page {
width: min(100%, 1420px);
min-height: calc(100dvh - 78px);
margin: 0 auto;
padding: 38px 32px 64px;
box-sizing: border-box;
}
.filters {
display: flex;
align-items: end;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filter-control {
display: flex;
min-width: 230px;
flex-direction: column;
gap: 6px;
color: #3e444d;
font-size: 14px;
}
.filter-control.short {
min-width: 140px;
}
.filter-box {
display: flex;
min-height: 56px;
align-items: center;
gap: 10px;
padding: 0 14px;
border: 1px solid #bfc5cd;
border-radius: 8px;
background: #fff;
}
.filter-box svg {
width: 23px;
height: 23px;
flex: none;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.filter-box input,
.filter-box select {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font-size: 18px;
}
.summary-card {
display: flex;
min-height: 170px;
align-items: center;
gap: 32px;
padding: 28px 34px;
}
.summary-icon {
display: grid;
width: 114px;
height: 114px;
flex: none;
place-items: center;
border-radius: 50%;
color: var(--pos-blue);
background: var(--pos-blue-soft);
}
.summary-icon svg {
width: 58px;
height: 58px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.summary-card p {
margin: 0 0 4px;
font-size: 19px;
}
.summary-card strong {
display: block;
font-size: 48px;
line-height: 1.05;
letter-spacing: -.035em;
}
.summary-card small {
display: block;
margin-top: 6px;
color: var(--pos-muted);
font-size: 18px;
}
.sales-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(350px, 1fr);
gap: 20px;
align-items: start;
margin-top: 20px;
}
.sales-right {
display: flex;
flex-direction: column;
gap: 20px;
}
::deep .ui-card {
overflow: hidden;
border: 1px solid var(--pos-border);
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(16, 24, 40, .03);
}
::deep .ui-card__header {
padding: 26px 32px 16px;
}
::deep .ui-card__header h2 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
::deep .ui-card__body {
padding: 0 32px 28px;
}
::deep .metric-list__header,
::deep .metric-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
}
::deep .metric-list__header {
padding-bottom: 12px;
color: var(--pos-muted);
}
::deep .metric-row {
min-height: 58px;
align-items: center;
border-top: 1px solid var(--pos-border-soft);
font-size: 19px;
}
::deep .metric-row > :last-child,
::deep .metric-list__header > :last-child {
text-align: right;
white-space: nowrap;
}
.print-button {
display: flex;
width: 100%;
min-height: 70px;
align-items: center;
justify-content: center;
gap: 14px;
border: 1.5px solid var(--pos-blue);
border-radius: 9px;
color: var(--pos-blue);
background: #fff;
font-weight: 700;
}
.print-button:hover {
background: var(--pos-blue-soft);
}
.print-button svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
@media (min-width: 900px) and (max-height: 850px) {
.today-sales-page {
padding-top: 22px;
padding-bottom: 18px;
}
.filters {
margin-bottom: 14px;
}
.filter-box {
min-height: 48px;
}
.summary-card {
min-height: 130px;
gap: 24px;
padding: 20px 28px;
}
.summary-icon {
width: 88px;
height: 88px;
}
.summary-icon svg {
width: 48px;
height: 48px;
}
.summary-card strong {
font-size: 40px;
}
.summary-card small {
font-size: 16px;
}
.sales-grid {
gap: 20px;
margin-top: 16px;
}
::deep .ui-card__header {
padding: 18px 28px 10px;
}
::deep .ui-card__body {
padding: 0 28px 16px;
}
::deep .metric-list__header {
padding-bottom: 7px;
}
::deep .metric-row {
min-height: 39px;
font-size: 17px;
}
.print-button {
min-height: 54px;
}
}
@media (max-width: 900px) {
.sales-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
.today-sales-page {
padding: 24px 16px 40px;
}
.summary-card {
gap: 18px;
padding: 22px 20px;
}
.summary-icon {
width: 72px;
height: 72px;
}
.summary-icon svg {
width: 40px;
height: 40px;
}
.summary-card strong {
font-size: 34px;
}
::deep .ui-card__header {
padding: 22px 20px 14px;
}
::deep .ui-card__body {
padding: 0 20px 22px;
}
::deep .metric-row {
font-size: 17px;
}
}

View File

@@ -1,12 +1,26 @@
@rendermode InteractiveServer
@rendermode InteractiveServer
@implements IDisposable
@inject NavigationManager NavigationManager
<header class="top-nav">
<div class="brand">Blomster POS</div>
<div class="nav-start">
@if (!IsHomePage)
{
<button class="home-button"
type="button"
title="Tilbage til forsiden"
aria-label="Tilbage til forsiden"
@onclick="GoToHome">
</button>
}
<div class="brand">Blomster POS</div>
</div>
<time datetime="@_currentTime.ToString("O")">
@_currentTime.ToString(
"dddd d. MMMM yyyy · HH:mm",
_danishCulture)
</time>
</header>
</header>

View File

@@ -5,11 +5,15 @@ namespace Pos.Web.Components.Shared
public partial class Header
{
private readonly CultureInfo _danishCulture =
CultureInfo.GetCultureInfo("da-DK");
CultureInfo.GetCultureInfo("da-DK");
private readonly Timer _timer;
private DateTime _currentTime = DateTime.Now;
private bool IsHomePage =>
string.IsNullOrEmpty(
NavigationManager.ToBaseRelativePath(NavigationManager.Uri));
public Header()
{
_timer = new Timer(
@@ -25,6 +29,11 @@ namespace Pos.Web.Components.Shared
_ = InvokeAsync(StateHasChanged);
}
private void GoToHome()
{
NavigationManager.NavigateTo("/");
}
public void Dispose()
{
_timer.Dispose();

View File

@@ -1,4 +1,3 @@
/* CSS for Header component */
.top-nav {
min-height: 72px;
box-sizing: border-box;
@@ -11,14 +10,45 @@
border-bottom: 1px solid var(--colorNeutralStroke2);
}
.nav-start {
display: flex;
align-items: center;
gap: 16px;
}
.brand {
color: var(--colorNeutralForeground1);
font-size: 26px;
font-weight: 600;
}
.home-button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--colorNeutralStroke1);
border-radius: 8px;
background: transparent;
color: var(--colorNeutralForeground1);
cursor: pointer;
font-family: inherit;
font-size: 28px;
line-height: 1;
}
.home-button:hover {
background: var(--colorNeutralBackground1Hover);
}
.home-button:focus-visible {
outline: 2px solid var(--colorStrokeFocus2);
outline-offset: 2px;
}
time {
color: var(--colorNeutralForeground2);
font-size: 18px;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,10 @@
@typeparam TItem
<div class="metric-list">
@if (HeaderContent is not null) { <div class="metric-list__header">@HeaderContent</div> }
@foreach (var item in Items) { @RowTemplate(item) }
</div>
@code {
[Parameter] public IReadOnlyList<TItem> Items { get; set; } = Array.Empty<TItem>();
[Parameter] public RenderFragment? HeaderContent { get; set; }
[Parameter] public RenderFragment<TItem> RowTemplate { get; set; } = default!;
}

View File

@@ -0,0 +1,12 @@
<section class="ui-card @Class">
@if (!string.IsNullOrWhiteSpace(Title))
{
<div class="ui-card__header"><h2>@Title</h2></div>
}
<div class="ui-card__body">@ChildContent</div>
</section>
@code {
[Parameter] public string Title { get; set; } = string.Empty;
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter] public RenderFragment? ChildContent { get; set; }
}

View File

@@ -1,5 +1,6 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@@ -11,4 +12,7 @@
@using Pos.Web
@using Pos.Web.Components
@using Pos.Web.Components.Layout
@using Pos.Web.Components.Shared
@using Pos.Web.Components.Shared
@using Pos.Service

View File

@@ -0,0 +1,34 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Pos.Web/Pos.Web.csproj", "Pos.Web/"]
COPY ["Pos.Models/Pos.Models.csproj", "Pos.Models/"]
COPY ["Pos.ServiceDefaults/Pos.ServiceDefaults.csproj", "Pos.ServiceDefaults/"]
COPY ["Pos.Service/Pos.Service.csproj", "Pos.Service/"]
COPY ["Pos.Ui/Database/Database.csproj", "Pos.Ui/Database/"]
RUN dotnet restore "./Pos.Web/Pos.Web.csproj"
COPY . .
WORKDIR "/src/Pos.Web"
RUN dotnet build "./Pos.Web.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Pos.Web.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Pos.Web.dll"]

View File

@@ -5,11 +5,14 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<UserSecretsId>1b2d8446-d538-488a-94a9-4568bdc91cd3</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="5.0.0-rc.4-26180.1" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.14.2" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,31 +1,34 @@
using Microsoft.FluentUI.AspNetCore.Components;
using Pos.Web.Components;
using Pos.Service;
using Pos.Web.Components;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddFluentUIComponents();
builder.Services.AddScoped<EmployeeService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<ProductGroupService>();
var app = builder.Build();
app.MapDefaultEndpoints();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseStatusCodePagesWithReExecute(
"/not-found",
createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();

View File

@@ -1,23 +1,36 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5281",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7102;http://localhost:5281",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5281"
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7102;http://localhost:5281"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080",
"SqlServer": "Server=host.docker.internal;Database=PointOfSale;Trusted_Connection=True;TrustServerCertificate=True;"
},
"publishAllPorts": true,
"useSSL": true
}
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

@@ -1,4 +1,4 @@
@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css';
@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css';
body {
--body-font: "Segoe UI Variable", "Segoe UI", sans-serif;
@@ -87,3 +87,51 @@ body {
code {
color: #c02d76;
}
:root {
--pos-blue: #155eef;
--pos-blue-soft: #eaf1ff;
--pos-text: #171a1f;
--pos-muted: #707781;
--pos-border: #d9dde3;
--pos-border-soft: #e9ebef;
--pos-bg: #f7f8fa;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--pos-bg);
color: var(--pos-text);
font-family: "Segoe UI Variable", "Segoe UI", Arial, sans-serif;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
.main {
min-height: 100dvh;
background: var(--pos-bg);
}
.content {
width: 100%;
padding: 0;
}
code {
color: #c02d76;
}

View File

@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.4.11620.152
@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pos.Models", "Pos.Models\Po
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pos.ServiceDefaults", "Pos.ServiceDefaults\Pos.ServiceDefaults.csproj", "{EDC882E6-4983-35BD-3357-E433A3E64369}"
EndProject
Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{B7F5A7E1-7A2B-4F3A-9D77-5C9C8A4B2E31}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -67,6 +69,10 @@ Global
{EDC882E6-4983-35BD-3357-E433A3E64369}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EDC882E6-4983-35BD-3357-E433A3E64369}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EDC882E6-4983-35BD-3357-E433A3E64369}.Release|Any CPU.Build.0 = Release|Any CPU
{B7F5A7E1-7A2B-4F3A-9D77-5C9C8A4B2E31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B7F5A7E1-7A2B-4F3A-9D77-5C9C8A4B2E31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7F5A7E1-7A2B-4F3A-9D77-5C9C8A4B2E31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7F5A7E1-7A2B-4F3A-9D77-5C9C8A4B2E31}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -0,0 +1,8 @@
<Project ToolsVersion="Current" Sdk="Microsoft.Docker.Sdk">
<PropertyGroup>
<ProjectVersion>2.1</ProjectVersion>
<DockerDevelopmentMode>Regular</DockerDevelopmentMode>
<DockerTargetOS>Linux</DockerTargetOS>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,35 @@
services:
db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_PID: "Developer"
MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD}"
ports:
- "11433:1433"
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q \"SELECT 1\" || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
volumes:
- mssql-system:/var/opt/mssql
- ./database-data/data:/var/opt/mssql/data
- ./database-backup:/var/opt/mssql/backup:ro
pointofsale:
build:
context: .
dockerfile: Pos.Web/Dockerfile
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
SqlServer: "Server=db,1433;Database=pointofsale;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=True;"
volumes:
mssql-system: