Addet ToDaySales page

This commit is contained in:
Alex Pedersen
2026-08-06 13:43:04 +02:00
parent c1695b4967
commit 49a9f336b1
19 changed files with 589 additions and 34 deletions

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 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

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Database.Models;
@@ -62,12 +62,12 @@ namespace Pos.Service
if (orderedProductIds.Any(id => id <= 0))
throw new ArgumentException(
"Rækkefølgen indeholder et ugyldigt id.",
"Rækkefølgen indeholder et ugyldigt id.",
nameof(orderedProductIds));
if (orderedProductIds.Distinct().Count() != orderedProductIds.Count)
throw new ArgumentException(
"Rækkefølgen indeholder dubletter.",
"Rækkefølgen indeholder dubletter.",
nameof(orderedProductIds));
_productRepository.SetOrder(
@@ -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

@@ -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">

View File

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

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">
Vare
</FluentButton>
</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,4 +1,4 @@
using System.Globalization;
using System.Globalization;
namespace Pos.Web.Components.Shared
{

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
@@ -12,3 +13,6 @@
@using Pos.Web.Components
@using Pos.Web.Components.Layout
@using Pos.Web.Components.Shared
@using Pos.Service

View File

@@ -1,31 +1,33 @@
using Microsoft.FluentUI.AspNetCore.Components;
using Pos.Web.Components;
using Microsoft.FluentUI.AspNetCore.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>();
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,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;
}