Building a Point of Sale Application in Blazor WebAssembly .NET 7 C# Tutorial

In this tutorial, we will be building a POS or point of sale application that allows us to manage products, update stock levels and perform a sale.

Here is a short demo of what the finished application will look like:

We will create areas where products and their categories can be created and updated.

We will be able to conduct a sale by browsing for products via their categories. We will also be able to apply a discount to the sale before adding payment and completing the sale.

Finally, we will be able to add stock for each of our products and calculate stock remaining based on stock added and sales conducted.

In this tutorial, we will be building an ASP.NET Core hosted Blazor WebAssembly application in .NET 7. We will also use Individual Accounts as the authentication type.

Jump to section:

Seeding an administrator user and role

Product and category CRUD

Adding stock

Creating a sale

Calculating stock remaining

Displaying sale details

Building a dashboard

Conclusion and GitHub repository

Seeding an administrator user and role

All pages of our application will be authenticated and will therefore require the user to sign in as an administrator to access them. In order for this to happen, we must ensure that an administrator account exists.

Firstly, we must configure our database and user class. In the Server project, create the following ApplicationUser class:

public class ApplicationUser : IdentityUser
{
}

Next, update ApplicationDbContext to use our new class:

public class ApplicationDbContext : ApiAuthorizationDbContext<ApplicationUser>

Then update Program.cs to also use ApplicationUser and to also use Roles:

builder.Services.AddDefaultIdentity<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddIdentityServer()
    .AddApiAuthorization<ApplicationUser, ApplicationDbContext>();

Now that our user class is created and configured, we can seed our administrator user. Next is one of the simplest ways of seeding a user in ASP.NET Identity.

Add a New Scaffolded Item, select Identity and choose Account\Login.

Update Login.cshtml.cs to include the following variables and constructor:

private readonly SignInManager<ApplicationUser> _signInManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly ILogger<LoginModel> _logger;
private readonly string _administratorEmail;
private readonly string _administratorRoleName;

public LoginModel(SignInManager<ApplicationUser> signInManager, 
    RoleManager<IdentityRole> roleManager, 
    ILogger<LoginModel> logger)
{
    _signInManager = signInManager;
    _roleManager = roleManager;
    _logger = logger;
    _administratorEmail = "administrator@trystanwilcock.com";
    _administratorRoleName = "Administrator";
}

Add the following SeedAdministratorUserAndRole method:

private async Task SeedAdministratorUserAndRole()
{
    ApplicationUser administratorUser = await _signInManager.UserManager.FindByEmailAsync(_administratorEmail);

    if (administratorUser == null)
    {
        administratorUser = new ApplicationUser
        {
            Email = _administratorEmail,
            NormalizedEmail = _administratorEmail.ToUpper(),
            UserName = _administratorEmail,
            EmailConfirmed = true
        };

        _logger.LogInformation("Creating administrator user.");
        await _signInManager.UserManager.CreateAsync(administratorUser, "Password1!");
    }
    else
    {
        _logger.LogInformation("Administrator user exists.");
    }

    bool administratorRoleExists = await _roleManager.RoleExistsAsync(_administratorRoleName);
    if (!administratorRoleExists)
    {
        IdentityRole newAdministratorRole = new IdentityRole
        {
            Name = _administratorRoleName,
            NormalizedName = _administratorRoleName.ToUpper()
        };

        _logger.LogInformation("Creating administrator role.");
        await _roleManager.CreateAsync(newAdministratorRole);
    }
    else
    {
        _logger.LogInformation("Administrator role exists.");
    }

    bool administratorUserIsInAdministratorRole = await _signInManager.UserManager.IsInRoleAsync(administratorUser, _administratorRoleName);

    if (!administratorUserIsInAdministratorRole)
    {
        _logger.LogInformation("Adding administrator user to administrator role.");
        await _signInManager.UserManager.AddToRoleAsync(administratorUser, _administratorRoleName);
    }
    else
    {
        _logger.LogInformation("Administrator user is already in administrator role.");
    }
}

Finally, simply call our method at the of the OnGetAsync method:

await SeedAdministratorUserAndRole();

Product and category CRUD

Firstly, we’ll build our Category CRUD. In the Server project, create a folder called Models, and inside, create the following ProductCategory class:

public class ProductCategory
{
    public int Id { get; set; }

    public string? Name { get; set; }
}

Next, create the following ProductCategoryController:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProductCategoryController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IMapper _mapper;
    private readonly ILogger<ProductCategoryController> _logger;

    public ProductCategoryController(ApplicationDbContext context,
        IMapper mapper,
        ILogger<ProductCategoryController> logger)
    {
        _context = context;
        _mapper = mapper;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IEnumerable<ProductCategoryViewModel>> Get()
    {
        return await _context
            .ProductCategories
            .ProjectTo<ProductCategoryViewModel>(_mapper.ConfigurationProvider)
            .OrderBy(pc => pc.Name)
            .ToArrayAsync();
    }

    [HttpGet("getcreateupdatedto/{id:int}")]
    public async Task<CreateUpdateProductCategoryDTO> GetCreateUpdateDTO(int id)
    {
        return await _context
            .ProductCategories
            .Where(pc => pc.Id == id)
            .ProjectTo<CreateUpdateProductCategoryDTO>(_mapper.ConfigurationProvider)
            .FirstAsync();
    }

    [HttpPost]
    public async Task Create(CreateUpdateProductCategoryDTO productCategoryDTO)
    {
        await _context.AddAsync(_mapper.Map<ProductCategory>(productCategoryDTO));
        await _context.SaveChangesAsync();
    }

    [HttpPut]
    public async Task Update(CreateUpdateProductCategoryDTO productCategoryDTO)
    {
        ProductCategory productCategory = await _context
            .ProductCategories
            .FindAsync(productCategoryDTO.Id);
        _mapper.Map(productCategoryDTO, productCategory);
        _context.Update(productCategory);
        await _context.SaveChangesAsync();
    }

    [HttpDelete("{id:int}")]
    public async Task Delete(int id)
    {
        ProductCategory productCategory = await _context
            .ProductCategories
            .FindAsync(id);
        _context.Remove(productCategory);
        await _context.SaveChangesAsync();
    }
}

In the Shared project, create a folder named ViewModels and add the following ProductCategoryViewModel:

public class ProductCategoryViewModel
{
    public int Id { get; set; }

    public string Name { get; set; }
}

Create another folder named DTOs and add the following CreateUpdateProductCategoryDTO:

public class CreateUpdateProductCategoryDTO
{
    public int Id { get; set; }

    [Required]
    [StringLength(50)]
    public string Name { get; set; }
}

Back in the Server project, install AutoMapper then create another folder named Classes and add the following AutoMapper profile:

public class WASMPointOfSaleAutoMapperProfile : Profile
{
    public WASMPointOfSaleAutoMapperProfile()
    {
        CreateMap<ProductCategory, ProductCategoryViewModel>();
        CreateMap<ProductCategory, CreateUpdateProductCategoryDTO>().ReverseMap();
    }
}

Don’t forget to configure AutoMapper in Program.cs:

builder.Services.AddAutoMapper(typeof(WASMPointOfSaleAutoMapperProfile));

In the Client project, add the following ProductCategoryIndex page:

@page "/productcategory"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime

<PageTitle>Product Categories</PageTitle>

<h1>Product Categories</h1>

<p>
    <button @onclick="Create" class="btn btn-success">Create</button>
</p>

@if (productCategories == null)
{
    <Loader />
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Name</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @foreach (var productCategory in productCategories)
            {
                <tr>
                    <td>@productCategory.Name</td>
                    <td class="text-end">
                        <button @onclick="() => Edit(productCategory.Id)" class="btn btn-warning">Edit</button>
                        <button @onclick="() => Delete(productCategory.Id, productCategory.Name)" class="btn btn-danger">Delete</button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

@code {

    private ProductCategoryViewModel[]? productCategories;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            await Get();
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }

    private async Task Get() =>
        productCategories = await Http.GetFromJsonAsync<ProductCategoryViewModel[]>("api/productcategory");

    private void Create() =>
        NavigationManager.NavigateTo("productcategory/create");

    private void Edit(int id) =>
        NavigationManager.NavigateTo($"productcategory/edit/{id}");

    private async Task Delete(int id, string name)
    {
        bool confirmed = await JsRuntime.InvokeAsync<bool>("confirm", $"Delete Product Category {name}?");
        if (confirmed)
        {
            await Http.DeleteAsync($"api/productcategory/{id}");
            await Get();
        }
    }
}

Then, add the following CreateUpdateProductCategory page:

@page "/productcategory/create"
@page "/productcategory/edit/{Id:int}"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager

@if (Id == 0)
{
    <h1>Create Product Category</h1>
}
else
{
    <h1>Edit Product Category</h1>
}

@if (productCategory == null)
{
    <Loader />
}
else
{
    <EditForm Model="@productCategory" OnValidSubmit="@HandleValidSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="mb-3 col-3">
            <label for="Name" class="form-label">Name</label>
            <InputText id="Name" @bind-Value="productCategory!.Name" class="form-control" />
        </div>

        <button type="button" @onclick="Back" class="btn btn-secondary">Back</button>
        <button type="submit" class="btn btn-primary">Submit</button>
    </EditForm>
}

@code {
    [Parameter] public int Id { get; set; }

    CreateUpdateProductCategoryDTO? productCategory = null;

    protected override async Task OnInitializedAsync()
    {
        if (Id == 0)
            productCategory = new();
        else
            productCategory = await Http.GetFromJsonAsync<CreateUpdateProductCategoryDTO>($"api/productcategory/getcreateupdatedto/{Id}");
    }

    private async Task HandleValidSubmit()
    {
        if (Id == 0)
            await Http.PostAsJsonAsync<CreateUpdateProductCategoryDTO>("api/productcategory", productCategory!);
        else
            await Http.PutAsJsonAsync<CreateUpdateProductCategoryDTO>("api/productcategory", productCategory!);

        NavigationManager.NavigateTo("productcategory");
    }

    private void Back() =>
        NavigationManager.NavigateTo("productcategory");
}

Now that we’ve created our Category management screens, we can move on to the Product CRUD. Firstly, add the Product model:

public class Product
{
    public int Id { get; set; }

    public int ProductCategoryId { get; set; }

    public string? Code { get; set; }

    public string? Name { get; set; }

    public string? Description { get; set; }

    public decimal Price { get; set; }

    public decimal Tax { get; set; }

    public int ReorderAtStockLevel { get; set; }


    public ProductCategory? ProductCategory { get; set; }
}

Next, add the ProductController:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IMapper _mapper;
    private readonly ILogger<ProductController> _logger;

    public ProductController(ApplicationDbContext context,
        IMapper mapper,
        ILogger<ProductController> logger)
    {
        _context = context;
        _mapper = mapper;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IEnumerable<ProductViewModel>> Get()
    {
        return await _context
            .Products
            .ProjectTo<ProductViewModel>(_mapper.ConfigurationProvider)
            .OrderBy(pc => pc.Name)
            .ToArrayAsync();
    }

    [HttpGet("getcreateupdatedto/{id:int}")]
    public async Task<CreateUpdateProductDTO> GetCreateUpdateDTO(int id)
    {
        return await _context
            .Products
            .Where(pc => pc.Id == id)
            .ProjectTo<CreateUpdateProductDTO>(_mapper.ConfigurationProvider)
            .FirstAsync();
    }

    [HttpPost]
    public async Task Create(CreateUpdateProductDTO productDTO)
    {
        await _context.AddAsync(_mapper.Map<Product>(productDTO));
        await _context.SaveChangesAsync();
    }

    [HttpPut]
    public async Task Update(CreateUpdateProductDTO productDTO)
    {
        Product product = await _context
            .Products
            .FindAsync(productDTO.Id);
        _mapper.Map(productDTO, product);
        _context.Update(product);
        await _context.SaveChangesAsync();
    }

    [HttpDelete("{id:int}")]
    public async Task Delete(int id)
    {
        Product product = await _context
            .Products
            .FindAsync(id);
        _context.Remove(product);
        await _context.SaveChangesAsync();
    }
}

Add the following ProductViewModel:

public class ProductViewModel
{
    public int Id { get; set; }

    public int ProductCategoryId { get; set; }

    public string Code { get; set; }

    public string Name { get; set; }

    public decimal Price { get; set; }

    public decimal Tax { get; set; }
}

Add the following CreateUpdateProductDTO:

public class CreateUpdateProductDTO
{
    public int Id { get; set; }

    public int ProductCategoryId { get; set; }

    [Required]
    [StringLength(20)]
    public string Code { get; set; }

    [Required]
    [StringLength(50)]
    public string Name { get; set; }

    [Required]
    [StringLength(200)]
    public string Description { get; set; }

    [Range(0, double.MaxValue, ErrorMessage = "Price must be 0 or greater.")]
    public decimal Price
    {
        get
        {
            return _price;
        }
        set
        {
            _price = value;

            if (Id == 0)
            {
                Tax = _price * (decimal)0.2;
            }
        }
    }

    private decimal _price;

    public decimal Tax { get; set; }

    [Range(0, int.MaxValue, ErrorMessage = "Reorder stock level must be 0 or greater.")]
    public int ReorderAtStockLevel { get; set; }
}

Add the following mappings to our AutoMapper profile:

CreateMap<Product, ProductViewModel>();
CreateMap<Product, CreateUpdateProductDTO>().ReverseMap();

Next, we can add the following ProductIndex and CreateUpdateProduct pages:

@page "/product"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime

<PageTitle>Products</PageTitle>

<h1>Products</h1>

<p>
    <button @onclick="Create" class="btn btn-success">Create</button>
</p>

@if (products == null)
{
    <Loader />
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Code</th>
                <th>Name</th>
                <th>Price</th>
                <th>Tax</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @foreach (var product in products)
            {
                <tr>
                    <td>@product.Code</td>
                    <td>@product.Name</td>
                    <td>@product.Price</td>
                    <td>@product.Tax</td>
                    <td class="text-end">
                        <button @onclick="() => Edit(product.Id)" class="btn btn-warning">Edit</button>
                        <button @onclick="() => Delete(product.Id, product.Name)" class="btn btn-danger">Delete</button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

@code {

    private ProductViewModel[]? products;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            await Get();
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }

    private async Task Get() =>
        products = await Http.GetFromJsonAsync<ProductViewModel[]>("api/product");

    private void Create() =>
        NavigationManager.NavigateTo("product/create");

    private void Edit(int id) =>
        NavigationManager.NavigateTo($"product/edit/{id}");

    private async Task Delete(int id, string name)
    {
        bool confirmed = await JsRuntime.InvokeAsync<bool>("confirm", $"Delete Product {name}?");
        if (confirmed)
        {
            await Http.DeleteAsync($"api/product/{id}");
            await Get();
        }
    }
}
@page "/product/create"
@page "/product/edit/{Id:int}"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager

@if (Id == 0)
{
    <h1>Create Product</h1>
}
else
{
    <h1>Edit Product</h1>
}

@if (productCategories == null && product == null)
{
    <Loader />
}
else
{
    <EditForm Model="@product" OnValidSubmit="@HandleValidSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="row">
            <div class="mb-3 col-4">
                <label for="ProductCategoryId" class="form-label">Category</label>
                <InputSelect id="ProductCategoryId" @bind-Value="product!.ProductCategoryId" class="form-control">
                    @foreach (var category in productCategories!)
                    {
                        <option value="@category.Value">@category.Text</option>
                    }
                </InputSelect>
            </div>
            <div class="mb-3 col-4">
                <label for="Code" class="form-label">Code</label>
                <InputText id="Code" @bind-Value="product!.Code" class="form-control" />
            </div>
            <div class="mb-3 col-4">
                <label for="Name" class="form-label">Name</label>
                <InputText id="Name" @bind-Value="product!.Name" class="form-control" />
            </div>
        </div>

        <div class="mb-3">
            <label for="Description" class="form-label">Description</label>
            <InputText id="Description" @bind-Value="product!.Description" class="form-control" />
        </div>

        <div class="row">
            <div class="mb-3 col-2">
                <label for="Price" class="form-label">Price</label>
                <InputNumber id="Price" @bind-Value="product!.Price" class="form-control" />
            </div>
            <div class="mb-3 col-2">
                <label for="Tax" class="form-label">Tax</label>
                <InputNumber id="Tax" @bind-Value="product!.Tax" class="form-control" />
                @if (product.Id == 0)
                {
                    <p class="small fst-italic">This will default to 20% of the price. If the tax on this item is different, amend this value. If the item is exempt from tax, set this value to 0.</p>
                }
            </div>
            <div class="mb-3 col-2">
                <label for="ReorderAtStockLevel" class="form-label">Reorder Stock Level</label>
                <InputNumber id="ReorderAtStockLevel" @bind-Value="product!.ReorderAtStockLevel" class="form-control" />
            </div>
        </div>

        <button type="button" @onclick="Back" class="btn btn-secondary">Back</button>
        <button type="submit" class="btn btn-primary">Submit</button>
    </EditForm>
}

@code {
    [Parameter] public int Id { get; set; }

    SelectListItemDTO[]? productCategories = null;
    CreateUpdateProductDTO? product = null;

    protected override async Task OnInitializedAsync()
    {
        productCategories = await Http.GetFromJsonAsync<SelectListItemDTO[]>("api/productcategory/getselectlistitems");

        if (Id == 0)
            product = new CreateUpdateProductDTO { ProductCategoryId = productCategories!.First().Value };
        else
            product = await Http.GetFromJsonAsync<CreateUpdateProductDTO>($"api/product/getcreateupdatedto/{Id}");
    }

    private async Task HandleValidSubmit()
    {
        if (Id == 0)
            await Http.PostAsJsonAsync<CreateUpdateProductDTO>("api/product", product!);
        else
            await Http.PutAsJsonAsync<CreateUpdateProductDTO>("api/product", product!);

        NavigationManager.NavigateTo("product");
    }

    private void Back() =>
        NavigationManager.NavigateTo("product");
}

Next, we need to update our ProductCategoryController to serve us a list of categories that we can use for the dropdown in our product form. Back in the Server project, add the following method:

[HttpGet("getselectlistitems")]
public async Task<IEnumerable<SelectListItemDTO>> GetSelectListItems()
{
    return await _context
        .ProductCategories
        .ProjectTo<SelectListItemDTO>(_mapper.ConfigurationProvider)
        .ToArrayAsync();
}

Add the following SelectListItemDTO:

public class SelectListItemDTO
{
    public string Text { get; set; }

    public int Value { get; set; }
}

Finally, add the following mapping:

CreateMap<ProductCategory, SelectListItemDTO>()
    .ForMember(dest => dest.Text, opt => opt.MapFrom(src => src.Name))
    .ForMember(dest => dest.Value, opt => opt.MapFrom(src => src.Id));

Adding stock

Now we have our products, we can build the capability to add stock for each product. Firstly, in the Server project, create and migrate the following Stock model:

public class Stock
{
    public int Id { get; set; }

    public int ProductId { get; set; }

    public int Quantity { get; set; }


    public Product? Product { get; set; }
}

In the Shared project, create the following StockViewModel:

public class StockViewModel
{
    public int ProductId { get; set; }

    public string ProductName { get; set; } = default!;

    public int StockAdded { get; set; }
}

Next, add the following AddStockDTO:

public class AddStockDTO
{
    public int ProductId { get; set; }

    [Range(1, double.MaxValue, ErrorMessage = "Quantity must be at least 1.")]
    public int Quantity { get; set; }
}

Now add the following StockController in the Server project:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class StockController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IMapper _mapper;
    private readonly ILogger<StockController> _logger;

    public StockController(ApplicationDbContext context,
        IMapper mapper,
        ILogger<StockController> logger)
    {
        _context = context;
        _mapper = mapper;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IEnumerable<StockViewModel>> Get()
    {
        var stockRecords = await (from p in _context.Products
                                  select new StockViewModel
                                  {
                                      ProductId = p.Id,
                                      ProductName = p.Name,
                                      StockAdded = (
                                        (from s in _context.Stocks
                                         where s.ProductId == p.Id
                                         select s.Quantity).Sum()
                                      )
                                  })
                          .ToArrayAsync();

        return stockRecords
            .OrderBy(vm => vm.StockAdded)
            .ThenBy(vm => vm.ProductName);
    }

    [HttpPost]
    public async Task Add(AddStockDTO addStockDTO)
    {
        await _context.AddAsync(_mapper.Map<Stock>(addStockDTO));
        await _context.SaveChangesAsync();
    }
}

We also require the following mapping:

CreateMap<AddStockDTO, Stock>();

Now that we’ve built the back end, we can add our pages to allow us to add stock and display how much stock we’ve added.

Add the following StockIndex page:

@page "/stock"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager

<PageTitle>Manage Stock Levels</PageTitle>

<h1>Manage Stock Levels</h1>

<p>
    <button @onclick="() => Add()" class="btn btn-success">Add Stock</button>
</p>

@if (stockLevels == null)
{
    <Loader />
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Product</th>
                <th>Stock Added</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @foreach (var stockLevel in stockLevels)
            {
                <tr>
                    <td>@stockLevel.ProductName</td>
                    <td>@stockLevel.StockAdded</td>
                    <td class="text-end">
                        <button @onclick="() => Add(stockLevel.ProductId)" class="btn btn-success">Add Stock</button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

@code {

    private StockViewModel[]? stockLevels;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            await Get();
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }

    private async Task Get() =>
        stockLevels = await Http.GetFromJsonAsync<StockViewModel[]>("api/stock");

    private void Add(int? productId = null)
    {
        if (productId == null)
            NavigationManager.NavigateTo("stock/add");
        else
            NavigationManager.NavigateTo($"stock/add/{productId}");
    }
}

Next, add the following AddStock page:

@page "/stock/add"
@page "/stock/add/{ProductId:int}"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager

<h1>Add Stock</h1>

@if (products == null)
{
    <Loader />
}
else
{
    <EditForm Model="@addStockDTO" OnValidSubmit="@HandleValidSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="row">
            <div class="mb-3 col-6">
                <label for="ProductId" class="form-label">Product</label>
                <InputSelect id="ProductId" @bind-Value="addStockDTO!.ProductId" class="form-control">
                    @foreach (var product in products!)
                    {
                        <option value="@product.Value">@product.Text</option>
                    }
                </InputSelect>
            </div>
            <div class="mb-3 col-2">
                <label for="Quantity" class="form-label">Quantity</label>
                <InputNumber id="Quantity" @bind-Value="addStockDTO!.Quantity" class="form-control" />
            </div>
        </div>

        <button type="button" @onclick="Back" class="btn btn-secondary">Back</button>
        <button type="submit" class="btn btn-primary">Submit</button>
    </EditForm>
}

@code {
    [Parameter] public int ProductId { get; set; }

    SelectListItemDTO[]? products = null;
    AddStockDTO? addStockDTO = null;

    protected override async Task OnInitializedAsync()
    {
        products = await Http.GetFromJsonAsync<SelectListItemDTO[]>("api/product/getselectlistitems");

        if (ProductId == 0)
            addStockDTO = new() { ProductId = products!.First().Value };
        else
            addStockDTO = new() { ProductId = ProductId };
    }

    private async Task HandleValidSubmit()
    {
        await Http.PostAsJsonAsync<AddStockDTO>("api/stock", addStockDTO!);
        NavigationManager.NavigateTo("stock");
    }

    private void Back() =>
        NavigationManager.NavigateTo("stock");
}

The only thing we’re missing here is the select list items for products. Add the following method to the ProductController, along with the following mapping:

[HttpGet("getselectlistitems")]
public async Task<IEnumerable<SelectListItemDTO>> GetSelectListItems()
{
    return await _context
        .Products
        .ProjectTo<SelectListItemDTO>(_mapper.ConfigurationProvider)
        .OrderBy(s => s.Text)
        .ToArrayAsync();
}
CreateMap<Product, SelectListItemDTO>()
    .ForMember(dest => dest.Text, opt => opt.MapFrom(src => src.Name))
    .ForMember(dest => dest.Value, opt => opt.MapFrom(src => src.Id));

Creating a sale

Now that we have our products and categories, we can build a page that allows us to conduct a sale. Firstly, we’ll add our database tables. Add the following Sale, SaleProduct and SaleTransaction tables:

public class Sale
{
    public int Id { get; set; }

    public DateTime Timestamp { get; set; }

    public int Quantity { get; set; }

    public decimal Tax { get; set; }

    public decimal Total { get; set; }

    public decimal Discount { get; set; }

    public decimal Due { get; set; }


    public List<SaleProduct>? SaleProducts { get; set; }

    public List<SaleTransaction>? SaleTransactions { get; set; }
}
public class SaleProduct
{
    public int Id { get; set; }

    public int SaleId { get; set; }

    public DateTime Timestamp { get; set; }

    public string? Code { get; set; }

    public string? Name { get; set; }

    public decimal Price { get; set; }

    public decimal Tax { get; set; }


    public Sale? Sale { get; set; }
}
public class SaleTransaction
{
    public int Id { get; set; }

    public int SaleId { get; set; }

    public DateTime Timestamp { get; set; }

    public string? Type { get; set; }

    public decimal Amount { get; set; }


    public Sale? Sale { get; set; }
}

Next, we’ll create a Cart class that we’ll add products to as we’re conducting our sale. Create the following Cart class in the Shared project:

public class Cart
{
    public List<CartLine> Lines { get; set; }

    public Cart()
    {
        Lines = new();
        DiscountAmount = 0;
    }

    public int Quantity
    {
        get
        {
            return Lines.Sum(l => l.Quantity);
        }
    }

    public decimal Tax
    {
        get
        {
            return Lines.Sum(l => l.Tax);
        }
    }

    public decimal Total
    {
        get
        {
            return Lines.Sum(l => l.Total);
        }
    }

    public decimal DiscountAmount { get; set; }

    public decimal Due
    {
        get
        {
            if (DiscountAmount == 0)
            {
                return Total;
            }
            else
            {
                return Total - Total * DiscountAmount / 100;
            }
        }
    }

    public void AddToCart(ProductViewModel product)
    {
        var existingLine = GetCartLine(product.Code);

        if (existingLine != null)
        {
            existingLine.Quantity++;
        }
        else
        {
            Lines.Add(new CartLine
            {
                Product = product,
                Quantity = 1
            });
        }
    }

    public void RemoveFromCart(ProductViewModel product)
    {
        var existingLine = GetCartLine(product.Code);

        if (existingLine!.Quantity == 1)
        {
            Lines.Remove(existingLine);
        }
        else
        {
            existingLine.Quantity--;
        }
    }

    public int ProductQuantity(string productCode)
    {
        var existingLine = GetCartLine(productCode);

        return existingLine == null ? 0 : existingLine.Quantity;
    }

    private CartLine? GetCartLine(string productCode)
    {
        return Lines.FirstOrDefault(l => l.Product.Code == productCode);
    }
}

public class CartLine
{
    public ProductViewModel Product { get; set; }

    public int Quantity { get; set; }

    public decimal Total
    {
        get
        {
            return Product.Price * Quantity;
        }
    }

    public decimal Tax
    {
        get
        {
            return Product.Tax * Quantity;
        }
    }
}

Next, we’ll build the component where we’ll browse and select our products. Create the following ProductSelection component:

<div class="card" style="height: 700px; overflow-y: scroll;">
    <div class="card-body">
        @if (ProductCategories != null && Products != null)
        {
            if (filteredProducts != null)
            {
                <div class="row">
                    <div class="col-md-12">
                        <p>
                            <button type="button" @onclick="() => ClearProductCategory()" class="btn btn-sm btn-secondary">
                                Back
                            </button>
                            Category: @selectedProductCategory!.Name
                        </p>
                    </div>
                </div>
                <div class="row row-cols-1 row-cols-md-2 g-4">
                    @foreach (var product in filteredProducts)
                    {
                        <div class="col">
                            <div class="card h-100">
                                <div class="card-body">
                                    <p class="text-center mb-0">
                                        <button type="button" @onclick="() => SelectProduct(product)" class="btn btn-link">
                                            @product.Name
                                        </button>
                                    </p>
                                </div>
                            </div>
                        </div>
                    }
                </div>
            }
            else if (selectedProductCategory == null)
            {
                <div class="row row-cols-1 row-cols-md-2 g-4">
                    @foreach (var productCategory in ProductCategories)
                    {
                        <div class="col">
                            <div class="card h-100">
                                <div class="card-body">
                                    <p class="text-center mb-0">
                                        <button type="button" @onclick="() => SelectProductCategory(productCategory)" class="btn btn-link">
                                            @productCategory.Name
                                        </button>
                                    </p>
                                </div>
                            </div>
                        </div>
                    }
                </div>
            }
        }
    </div>
</div>

@code {
    [Parameter] public ProductCategoryViewModel[]? ProductCategories { get; set; }
    [Parameter] public ProductViewModel[]? Products { get; set; }
    [Parameter] public EventCallback<ProductViewModel> OnProductSelect { get; set; }

    ProductCategoryViewModel? selectedProductCategory = null;
    ProductViewModel[]? filteredProducts = null;

    private void SelectProductCategory(ProductCategoryViewModel productCategory)
    {
        selectedProductCategory = productCategory;
        filteredProducts = Products!.Where(p => p.ProductCategoryId == selectedProductCategory.Id).ToArray();
    }

    private void ClearProductCategory()
    {
        filteredProducts = null;
        selectedProductCategory = null;
    }

    private async Task SelectProduct(ProductViewModel product)
    {
        await OnProductSelect.InvokeAsync(product);
    }
}

If a category has been selected, we display all products that belong to that category. Otherwise, we display a list of categories. On the selection of a product, we will pass this back to the parent component, which will be our page.

Next we’ll create an endpoint where we can check that there is enough available stock of a product before adding it to the cart.

Add the following StockRequestDTO:

public class StockRequestDTO
{
    public int ProductId { get; set; }

    public int CartQuantity { get; set; }
}

Add the following StockRequest method to the StockController:

[HttpPost]
[Route("request")]
public async Task<bool> StockRequest(StockRequestDTO stockRequestDTO)
{
    var product = await _context
        .Products
        .FindAsync(stockRequestDTO.ProductId);

    var stock = _context
        .Stocks
        .Where(s => s.ProductId == stockRequestDTO.ProductId)
        .Sum(s => s.Quantity);

    var sold = _context
        .SaleProducts
        .Where(s => s.Code == product!.Code)
        .Count();

    return stock - sold - stockRequestDTO.CartQuantity > 0 ? true : false;
}

Before adding our page, we’ll now build a component to display what products we currently have in our cart. Create the following CartDisplay component:

@if (Cart != null)
{
    <div class="row mb-4">
        <div class="col-md-12">
            <div class="card">
                <div class="card-body">
                    <div style="height: 350px; overflow-y: scroll;">
                        <table class="table">
                            <thead>
                                <tr>
                                    <th>Name</th>
                                    <th>Price</th>
                                    <th>Quantity</th>
                                    <th></th>
                                </tr>
                            </thead>
                            <tbody>
                                @foreach (var line in Cart.Lines)
                                {
                                    <tr>
                                        <td>@line.Product.Name</td>
                                        <td>@line.Product.Price</td>
                                        <td>@line.Quantity</td>
                                        <td class="text-end">
                                            <button @onclick="() => OnRemoveProduct.InvokeAsync(line.Product)" class="btn btn-danger">Delete</button>
                                        </td>
                                    </tr>
                                }
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            Quantity
        </div>
        <div class="col-md-6 text-end">
            @Cart.Quantity
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            Tax
        </div>
        <div class="col-md-6 text-end">
            @Cart.Tax
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            Total
        </div>
        <div class="col-md-6 text-end">
            @Cart.Total
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            Discount (%)
        </div>
        <div class="col-md-6 text-end">
            @Cart.DiscountAmount
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            Due
        </div>
        <div class="col-md-6 text-end">
            @Cart.Due
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            <ApplyDiscount CurrentDiscountAmount="Cart.DiscountAmount"
                       OnApplyDiscount="HandleApplyDiscount" />
        </div>
        <div class="col-md-6">
            <AddPaymentButton AmountDue="Cart.Due"
                          OnAddPayment="HandleAddPayment"
                          ButtonText="Finish & Pay" />
        </div>
    </div>
}

@code {
    [Parameter] public Cart Cart { get; set; }
    [Parameter] public EventCallback<ProductViewModel> OnRemoveProduct { get; set; }
    [Parameter] public EventCallback<AddPaymentDTO> OnAddPayment { get; set; }

    public void HandleApplyDiscount(decimal discountAmount) =>
        Cart.DiscountAmount = discountAmount;

    public async Task HandleAddPayment(AddPaymentDTO addPaymentDTO) =>
        await OnAddPayment.InvokeAsync(addPaymentDTO);
}

Our component displays a list of products we have in our cart, along with the number of products, total tax, total, if any discount has been applied, and how much there is to pay.

Next, add the following ApplyDiscountDTO component:

public class ApplyDiscountDTO
{
    [Required]
    [Range(1, 99)]
    [Display(Name = "Discount Amount")]
    public decimal DiscountAmount { get; set; }
}

Then, add the following ApplyDiscount component:

<div class="d-grid gap-2">
    @if (!showForm)
    {
        <button class="btn btn-primary" type="button" @onclick="HandleOnClickApplyDiscount">Apply Discount</button>
    }
    else
    {
        <EditForm Model="@applyDiscountDTO" OnValidSubmit="@HandleValidSubmit">
            <DataAnnotationsValidator />
            <ValidationSummary />

            <div class="mb-3">
                <label for="DiscountAmount" class="form-label">Discount Amount</label>
                <InputNumber id="DiscountAmount" @bind-Value="applyDiscountDTO!.DiscountAmount" class="form-control" />
            </div>

            <button type="button" @onclick="() => showForm = false" class="btn btn-secondary">Cancel</button>
            <button type="submit" class="btn btn-success">Apply Discount</button>
        </EditForm>
    }
</div>

@code {
    [Parameter] public decimal CurrentDiscountAmount { get; set; }
    [Parameter] public EventCallback<decimal> OnApplyDiscount { get; set; }

    bool showForm = false;
    ApplyDiscountDTO? applyDiscountDTO;

    private void HandleOnClickApplyDiscount()
    {
        applyDiscountDTO = new() { DiscountAmount = CurrentDiscountAmount };
        showForm = true;
    }

    private async Task HandleValidSubmit()
    {
        await OnApplyDiscount.InvokeAsync(applyDiscountDTO!.DiscountAmount);
        showForm = false;
    }
}

In this component, a form is revealed where the user can enter a discount amount. This value is then passed to its parent component, which is CartDisplay. The amount is then applied to our cart.

We’ll complete our CartDisplay component by building the AddPaymentButton component. Start by adding the following AddPaymentDTO:

public class AddPaymentDTO
{
    public AddPaymentDTO(decimal amountDue)
    {
        AmountDue = amountDue;
    }

    [Required]
    [Range(0, double.MaxValue, ErrorMessage = "Amount cannot be below 0.")]
    [Display(Name = "Payment Amount")]
    public decimal PaymentAmount { get; set; }

    public decimal AmountDue { get; set; }

    public decimal LeftToPay
    {
        get
        {
            return AmountDue - PaymentAmount;
        }
    }

    public int SaleId { get; set; }
}

Then add the following AddPaymentButton:

<div class="d-grid gap-2">
    <button @onclick="ShowModal" class="btn btn-primary" type="button">@ButtonText</button>
</div>

@code {
    [CascadingParameter] public IModalService Modal { get; set; } = default!;
    [Parameter] public string ButtonText { get; set; } = default!;
    [Parameter] public decimal AmountDue { get; set; }
    [Parameter] public EventCallback<AddPaymentDTO> OnAddPayment { get; set; }

    public async Task ShowModal()
    {
        ModalParameters modalParameters = new();
        modalParameters.Add(nameof(AddPayment.AmountDue), AmountDue);
        var addPaymentModal = Modal.Show<AddPayment>("Add Payment", modalParameters);
        var result = await addPaymentModal.Result;
        if (result.Confirmed)
        {
            var addPaymentDTO = (AddPaymentDTO)result.Data!;
            await OnAddPayment.InvokeAsync(addPaymentDTO);
        }
    }
}

Next, add the following AddPayment modal:

@if (addPaymentDTO == null)
{
    <Loader />
}
else
{
    <p>
        Amount Due: @addPaymentDTO.AmountDue
    </p>

    <EditForm Model="@addPaymentDTO" OnValidSubmit="@HandleValidSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="mb-3">
            <label for="PaymentAmount" class="form-label">Payment Amount</label>
            <InputNumber id="PaymentAmount" @bind-Value="addPaymentDTO!.PaymentAmount" class="form-control" />
        </div>

        <p>
            Left to Pay: @addPaymentDTO.LeftToPay
        </p>

        <button type="button" @onclick="() => BlazoredModalInstance.CancelAsync()" class="btn btn-secondary">Cancel</button>
        <button type="submit" class="btn btn-success">Add Payment</button>
    </EditForm>
}

@code {
    [CascadingParameter] BlazoredModalInstance BlazoredModalInstance { get; set; } = default!;
    [Parameter] public decimal AmountDue { get; set; }

    AddPaymentDTO? addPaymentDTO;

    protected override void OnInitialized() =>
        addPaymentDTO = new(AmountDue) { PaymentAmount = AmountDue };

    public async Task HandleValidSubmit() =>
        await BlazoredModalInstance.CloseAsync(ModalResult.Ok(addPaymentDTO));
}

Similar to our ApplyDiscount component, the payment amount will get passed to AddPaymentButton, then to CartDisplay which will then pass it back to our page.

We have built all the components required for our page, so we can now add our NewSale page:

@page "/newsale"
@attribute [Authorize]
@inject HttpClient Http
@inject IJSRuntime JsRuntime

<PageTitle>New Sale</PageTitle>

<h1>New Sale</h1>

<div class="row">
    <div class="col-md-5">
        <CartDisplay Cart="cart"
                     OnRemoveProduct="HandleRemoveProduct"
                     OnAddPayment="HandleAddPayment" />
    </div>
    <div class="col-md-7">
        <ProductSelection ProductCategories="allProductCategories"
                          Products="allProducts"
                          OnProductSelect="HandleProductSelect" />
    </div>
</div>

@code {
    ProductCategoryViewModel[]? allProductCategories;
    ProductViewModel[]? allProducts;
    Cart cart = new();

    protected override async Task OnInitializedAsync()
    {
        allProductCategories = await Http.GetFromJsonAsync<ProductCategoryViewModel[]>("api/productcategory");
        allProducts = await Http.GetFromJsonAsync<ProductViewModel[]>("api/product");
    }

    private async Task HandleProductSelect(ProductViewModel product)
    {
        StockRequestDTO stockRequestDTO = new()
            {
                ProductId = product.Id,
                CartQuantity = cart.ProductQuantity(product.Code)
            };

        var response = await Http.PostAsJsonAsync<StockRequestDTO>("api/stock/request", stockRequestDTO);
        var canBook = await response.Content.ReadFromJsonAsync<bool>();

        if (canBook)
            cart.AddToCart(product);
        else
            await JsRuntime.InvokeVoidAsync("alert", "Unable to add to cart, insufficient stock.");
    }

    private void HandleRemoveProduct(ProductViewModel product)
    {
        cart.RemoveFromCart(product);
    }

    private async Task HandleAddPayment(AddPaymentDTO addPaymentDTO)
    {
        NewSaleDTO newSaleDTO = new() { Cart = cart, Payment = addPaymentDTO };
        await Http.PostAsJsonAsync<NewSaleDTO>("api/sale", newSaleDTO);
        cart = new();
    }
}

Note the HandleProductSelect handler that adds our selected product to our cart if there is sufficient stock remaining. We also have a handler for HandleRemoveProduct that is called from our CartDisplay component.

To conclude conducting a sale, let’s now build our endpoint that saves our sale to the database. Create the following NewSaleDTO:

public class NewSaleDTO
{
    public Cart Cart { get; set; }

    public AddPaymentDTO Payment { get; set; }
}

Now add the following SaleController:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class SaleController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IMapper _mapper;
    private readonly ILogger<SaleController> _logger;

    public SaleController(ApplicationDbContext context,
        IMapper mapper,
        ILogger<SaleController> logger)
    {
        _context = context;
        this._mapper = mapper;
        _logger = logger;
    }

    [HttpPost]
    public async Task NewSale(NewSaleDTO newSaleDTO)
    {
        Sale newSale = GetNewSale(newSaleDTO);
        await _context.AddAsync(newSale);
        await _context.SaveChangesAsync();
    }

    private static Sale GetNewSale(NewSaleDTO newSaleDTO)
    {
        DateTime timestamp = DateTime.Now;

        Sale sale = new()
        {
            Timestamp = timestamp,
            Quantity = newSaleDTO.Cart.Quantity,
            Tax = newSaleDTO.Cart.Tax,
            Total = newSaleDTO.Cart.Total,
            Discount = newSaleDTO.Cart.DiscountAmount,
            Due = newSaleDTO.Cart.Due,
            SaleProducts = GetNewSaleProducts(newSaleDTO.Cart.Lines, timestamp),
            SaleTransactions = GetNewSaleTransactions(newSaleDTO.Payment.PaymentAmount, timestamp)
        };

        return sale;
    }

    private static List<SaleProduct> GetNewSaleProducts(List<CartLine> cartLines, DateTime timestamp)
    {
        List<SaleProduct> saleProducts = new();

        foreach (var cartLine in cartLines)
        {
            for (int i = 0; i < cartLine.Quantity; i++)
            {
                saleProducts.Add(new SaleProduct
                {
                    Timestamp = timestamp,
                    Code = cartLine.Product.Code,
                    Name = cartLine.Product.Name,
                    Price = cartLine.Product.Price,
                    Tax = cartLine.Product.Tax
                });
            }
        }

        return saleProducts;
    }

    private static List<SaleTransaction> GetNewSaleTransactions(decimal paymentAmount, DateTime timestamp)
    {
        return new List<SaleTransaction>
        {
            new SaleTransaction
            {
                Timestamp = timestamp,
                Type = SaleTransactionType.Payment.ToString(),
                Amount = paymentAmount
            }
        };
    }
}

Calculating stock remaining

Now that we can add stock of a product and we can conduct a sale, we can update our stock table to show us the remaining stock levels of our products.

Update the StockViewModel to include the following fields:

public int ItemsSold { get; set; }

public int StockRemaining
{
    get
    {
        return StockAdded - ItemsSold;
    }
}

public int ReorderAtStockLevel { get; set; }

public bool StockLevelCritical
{
    get
    {
        return StockRemaining <= ReorderAtStockLevel ? true : false;
    }
}

Now update the Get method in the StockController to include values for our ItemsSold and ReorderAtStockLevel properties:

var stockRecords = await (from p in _context.Products
                          select new StockViewModel
                          {
                              ProductId = p.Id,
                              ProductName = p.Name,
                              StockAdded = (
                                (from s in _context.Stocks
                                 where s.ProductId == p.Id
                                 select s.Quantity).Sum()
                              ),
                              ItemsSold = (
                                (from s in _context.SaleProducts
                                 where s.Code == p.Code
                                 select s).Count()
                              ),
                              ReorderAtStockLevel = p.ReorderAtStockLevel
                          })
                  .ToArrayAsync();

Finally, update the table in the StockIndex page:

<table class="table">
    <thead>
        <tr>
            <th>Product</th>
            <th>Stock Added</th>
            <th>Items Sold</th>
            <th>Stock Remaining</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var stockLevel in stockLevels)
        {
            var sClass = stockLevel.StockLevelCritical == true ? "bg-danger text-light" : string.Empty;

            <tr class="@sClass">
                <td>@stockLevel.ProductName</td>
                <td>@stockLevel.StockAdded</td>
                <td>@stockLevel.ItemsSold</td>
                <td>@stockLevel.StockRemaining</td>
                <td class="text-end">
                    <button @onclick="() => Add(stockLevel.ProductId)" class="btn btn-success">Add Stock</button>
                </td>
            </tr>
        }
    </tbody>
</table>

If the stock remaining is equal to or less than the reorder value, the table row for that product is highlighted red.

Displaying sale details

Next, we’ll build a screen to show the details of a sale, including the products purchased and the amount paid. As we already have a component to obtain a payment amount from the user, we’ll also allow an additional payment to be added to a sale to accommodate for sales where the full amount has not been paid yet.

Create the following SaleDetailViewModel:

public class SaleDetailViewModel
{
    public DateTime Timestamp { get; set; }

    public int Quantity { get; set; }

    public decimal Tax { get; set; }

    public decimal Total { get; set; }

    public decimal Discount { get; set; }

    public decimal Due { get; set; }

    public decimal Paid { get; set; }

    public decimal Outstanding
    {
        get
        {
            return Due - Paid;
        }
    }


    public IEnumerable<SaleDetailProductViewModel>? Products { get; set; }

    public IEnumerable<SaleDetailTransactionViewModel>? Transactions { get; set; }
}

public class SaleDetailProductViewModel
{
    public string Code { get; set; }

    public string Name { get; set; }

    public decimal Price { get; set; }

    public decimal Tax { get; set; }
}

public class SaleDetailTransactionViewModel
{
    public string Type { get; set; }

    public decimal Amount { get; set; }
}

Next, add the following GetSaleDetails method to the SaleController, along with the accompanying mappings:

[HttpGet("details/{id:int}")]
public async Task<SaleDetailViewModel> GetSaleDetails(int id)
{
    return await _context
        .Sales
        .Where(s => s.Id == id)
        .Include(s => s.SaleProducts)
        .Include(s => s.SaleTransactions)
        .Select(s => new SaleDetailViewModel
        {
            Timestamp = s.Timestamp,
            Quantity = s.Quantity,
            Tax = s.Tax,
            Total = s.Total,
            Discount = s.Discount,
            Due = s.Due,
            Paid = s.SaleTransactions!.Where(st => st.Type == SaleTransactionType.Payment.ToString()).Sum(st => st.Amount),
            Products = _mapper.Map<IEnumerable<SaleDetailProductViewModel>>(s.SaleProducts),
            Transactions = _mapper.Map<IEnumerable<SaleDetailTransactionViewModel>>(s.SaleTransactions)
        })
        .FirstAsync();
}
CreateMap<SaleProduct, SaleDetailProductViewModel>();
CreateMap<SaleTransaction, SaleDetailTransactionViewModel>();

Now we’ve established the backend, we’ll build our page. Start by adding a SaleDetailCard:

<div class="card">
    <div class="card-body">
        <table class="table">
            <tr>
                <td>Timestamp</td>
                <td>@SaleDetailViewModel.Timestamp</td>
            </tr>
            <tr>
                <td>Quantity</td>
                <td>@SaleDetailViewModel.Quantity</td>
            </tr>
            <tr>
                <td>Tax</td>
                <td>@SaleDetailViewModel.Tax</td>
            </tr>
            <tr>
                <td>Total</td>
                <td>@SaleDetailViewModel.Total</td>
            </tr>
            <tr>
                <td>Discount</td>
                <td>@SaleDetailViewModel.Discount</td>
            </tr>
            <tr>
                <td>Due</td>
                <td>@SaleDetailViewModel.Due</td>
            </tr>
            <tr>
                <td>Paid</td>
                <td>@SaleDetailViewModel.Paid</td>
            </tr>
            <tr>
                <td>Outstanding</td>
                <td>@SaleDetailViewModel.Outstanding</td>
            </tr>
        </table>
    </div>
</div>

@code {
    [Parameter] public SaleDetailViewModel SaleDetailViewModel { get; set; } = default!;
}

Next, add the following SaleDetailProductCard component:

<div class="card">
    <div class="card-body">
        <table class="table">
            @foreach (var product in Products)
            {
                <tr>
                    <td>
                        @product.Code
                    </td>
                    <td>
                        @product.Name
                    </td>
                    <td>
                        @product.Price
                    </td>
                    <td>
                        @product.Tax
                    </td>
                </tr>
            }
        </table>
    </div>
</div>

@code {
    [Parameter] public IEnumerable<SaleDetailProductViewModel> Products { get; set; } = default!;
}

Next, add the following SaleDetailTransactionCard component:

<div class="card">
    <div class="card-body">
        @if (Outstanding > 0)
        {
            <AddPaymentButton AmountDue="Outstanding"
                          ButtonText="Add Payment" OnAddPayment="HandleAddPayment" />
        }
        <table class="table">
            @foreach (var transaction in Transactions)
            {
                <tr>
                    <td>
                        @transaction.Type
                    </td>
                    <td>
                        @transaction.Amount
                    </td>
                </tr>
            }
        </table>
    </div>
</div>

@code {
    [Parameter] public decimal Outstanding { get; set; }
    [Parameter] public IEnumerable<SaleDetailTransactionViewModel> Transactions { get; set; } = default!;
    [Parameter] public EventCallback<AddPaymentDTO> OnAddPayment { get; set; }

    private async Task HandleAddPayment(AddPaymentDTO addPaymentDTO) =>
        await OnAddPayment.InvokeAsync(addPaymentDTO);
}

Note we are using our AddPaymentButton component from earlier to obtain a payment amount from the user. This will be passed to a parent component.

Create the following SaleDetail component:

<div class="row">
    <div class="col-md-4">
        <SaleDetailCard SaleDetailViewModel="SaleDetailViewModel" />
    </div>
    <div class="col-md-4">
        <SaleDetailProductCard Products="SaleDetailViewModel.Products" />
    </div>
    <div class="col-md-4">
        <SaleDetailTransactionCard Outstanding="SaleDetailViewModel.Outstanding"
                                   Transactions="SaleDetailViewModel.Transactions"
                                   OnAddPayment="HandleAddPayment" />
    </div>
</div>

@code {
    [Parameter] public SaleDetailViewModel SaleDetailViewModel { get; set; } = default!;
    [Parameter] public EventCallback<AddPaymentDTO> OnAddPayment { get; set; }

    private async Task HandleAddPayment(AddPaymentDTO addPaymentDTO) =>
        await OnAddPayment.InvokeAsync(addPaymentDTO);
}

Finally, create the SaleDetailIndex page:

@page "/sale/{Id:int}"
@attribute [Authorize]
@inject HttpClient Http
@inject NavigationManager NavigationManager

<PageTitle>Sale Details</PageTitle>

<h1>Sale Details</h1>

@if (sale == null)
{
    <Loader />
}
else
{
    <SaleDetail SaleDetailViewModel="sale"
            OnAddPayment="HandleAddPayment" />

    <p class="mt-3">
        <button @onclick="Back" class="btn btn-secondary">Back</button>
    </p>
}

@code {
    [Parameter] public int Id { get; set; }

    private SaleDetailViewModel? sale;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            await Get();
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }

    private async Task HandleAddPayment(AddPaymentDTO addPaymentDTO)
    {
        addPaymentDTO.SaleId = Id;
        await Http.PostAsJsonAsync<AddPaymentDTO>("api/sale/addpayment", addPaymentDTO);
        await Get();
    }

    private async Task Get() =>
        sale = await Http.GetFromJsonAsync<SaleDetailViewModel>($"api/sale/details/{Id}");

    private void Back() =>
        NavigationManager.NavigateTo("sales");
}

Building a dashboard

To finish off our application we’ll create a dashboard on the home page to show us a few statistics. Start by adding the following DashboardViewModel:

public class DashboardViewModel
{
    public decimal TotalSales { get; set; }

    public int CriticalStock { get; set; }

    public int TotalItems { get; set; }

    public int TotalItemsSold { get; set; }
}

Create the following DashboardController:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DashboardController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly ILogger<SaleController> _logger;
    private readonly StockController _stockController;

    public DashboardController(ApplicationDbContext context,
        ILogger<SaleController> logger,
        StockController stockController)
    {
        _context = context;
        _logger = logger;
        _stockController = stockController;
    }

    [HttpGet]
    public async Task<DashboardViewModel> Get()
    {
        var totalSales = _context
            .Sales
            .Sum(s => s.Due);

        var stockRecords = await _stockController.Get();
        var criticalStockRecords = stockRecords
            .Where(s => s.StockLevelCritical);

        var totalItems = _context
            .Products
            .Count();

        var totalItemsSold = _context
            .SaleProducts
            .Select(p => p.Code)
            .Distinct()
            .Count();

        return new DashboardViewModel
        {
            TotalSales = totalSales,
            CriticalStock = criticalStockRecords.Count(),
            TotalItems = totalItems,
            TotalItemsSold = totalItemsSold
        };
    }
}

As we are injecting the StockController into our DashboardController, we’ll have to register it as a service. Add the following to Program.cs:

builder.Services.AddTransient<StockController>();

Finally, update the home page to the following:

@page "/"
@attribute [Authorize]
@inject HttpClient Http

<PageTitle>Index</PageTitle>

@if (dashboard == null)
{
    <Loader />
}
else
{
    <div class="card-group text-light">
        <div class="card bg-info m-2">
            <div class="card-body">
                <span class="oi oi-british-pound fs-1 me-4" aria-hidden="true"></span>
                <span class="fs-5">@dashboard.TotalSales Total Sales</span>
            </div>
        </div>
        <div class="card bg-danger m-2">
            <div class="card-body">
                <span class="oi oi-graph fs-1 me-4" aria-hidden="true"></span>
                <span class="fs-5">@dashboard.CriticalStock Critical Stock</span>
            </div>
        </div>
        <div class="card bg-success m-2">
            <div class="card-body">
                <span class="oi oi-list fs-1 me-4" aria-hidden="true"></span>
                <span class="fs-5">@dashboard.TotalItems Total Items</span>
            </div>
        </div>
        <div class="card bg-warning m-2">
            <div class="card-body">
                <span class="oi oi-basket fs-1 me-4" aria-hidden="true"></span>
                <span class="fs-5">@dashboard.TotalItemsSold Total Items Sold</span>
            </div>
        </div>
    </div>
}

@code {
    DashboardViewModel? dashboard;

    protected override async Task OnInitializedAsync() =>
        dashboard = await Http.GetFromJsonAsync<DashboardViewModel>($"api/dashboard");
}

Conclusion and GitHub repository

If you’ve reached the end of this tutorial and you’re reading this, congratulations! Only upon finishing this blog post have I realised how large it is.

If you’re reading this, I would also like to say thank you for sticking around and reading my blog. It truly means a lot, especially if you were able to learn just one thing. Thanks again!

That concludes this tutorial on how to build a point-of-sale application in Blazor Webassembly. The entire source code for this tutorial can be found in this GitHub repository.

3 thoughts on “Building a Point of Sale Application in Blazor WebAssembly .NET 7 C# Tutorial”

  1. Great article but I think you should remove context from your controllers and instead add a service which the controllers can call. Especially when teaching new developers – they should learn best practices from the get go.

  2. That’s great – please ping when done would love to see. This will make things testable and separate concerns. When I build apps I usually have 4 layers
    1. Front End
    2. Api
    3. Service
    4. Data

    I use injection (like you already do with logger, mapper and context) and can swap out any layer easily.
    Not saying this is the only correct way – but it is the Microsoft best practices’ way.

Comments are closed.

Scroll to Top