Description
Step 1: Add the Stock
Entity in the CleanAspire.Domain Project
To begin, we’ll define the Stock
entity in the CleanAspire.Domain project, under the Entities
directory. This entity will represent a stock record, containing details about the associated product, quantity, and location. We’ll also add a DbSet<Stock>
in the application database context to enable querying and saving Stock
records to the database.
Here’s how the Stock
entity should look:
/// <summary>
/// Represents a stock entity.
/// </summary>
public class Stock : BaseAuditableEntity, IAuditTrial
{
/// <summary>
/// Gets or sets the product ID.
/// </summary>
public string? ProductId { get; set; }
/// <summary>
/// Gets or sets the product associated with the stock.
/// </summary>
public Product? Product { get; set; }
/// <summary>
/// Gets or sets the quantity of the stock.
/// </summary>
public int Quantity { get; set; }
/// <summary>
/// Gets or sets the location of the stock.
/// </summary>
public string Location { get; set; } = string.Empty;
}
/// <summary>
/// Configures the Stock entity.
/// </summary>
public class StockConfiguration : IEntityTypeConfiguration<Stock>
{
/// <summary>
/// Configures the properties and relationships of the Stock entity.
/// </summary>
/// <param name="builder">The builder to be used to configure the Stock entity.</param>
public void Configure(EntityTypeBuilder<Stock> builder)
{
/// <summary>
/// Configures the ProductId property of the Stock entity.
/// </summary>
builder.Property(x => x.ProductId).HasMaxLength(50).IsRequired();
/// <summary>
/// Configures the relationship between the Stock and Product entities.
/// </summary>
builder.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
/// <summary>
/// Configures the Location property of the Stock entity.
/// </summary>
builder.Property(x => x.Location).HasMaxLength(12).IsRequired();
/// <summary>
/// Ignores the DomainEvents property of the Stock entity.
/// </summary>
builder.Ignore(e => e.DomainEvents);
}
}
public interface IApplicationDbContext
{
DbSet<Product> Products { get; set; }
DbSet<AuditTrail> AuditTrails { get; set; }
DbSet<Tenant> Tenants { get; set; }
DbSet<Stock> Stocks { get; set; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken=default);
}
/// <summary>
/// Represents the application database context.
/// </summary>
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IApplicationDbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="ApplicationDbContext"/> class.
/// </summary>
/// <param name="options">The options to be used by a <see cref="DbContext"/>.</param>
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
/// <summary>
/// Gets or sets the Tenants DbSet.
/// </summary>
public DbSet<Tenant> Tenants { get; set; }
/// <summary>
/// Gets or sets the AuditTrails DbSet.
/// </summary>
public DbSet<AuditTrail> AuditTrails { get; set; }
/// <summary>
/// Gets or sets the Products DbSet.
/// </summary>
public DbSet<Product> Products { get; set; }
/// <summary>
/// Gets or sets the Stocks DbSet.
/// </summary>
public DbSet<Stock> Stocks { get; set; }
/// <summary>
/// Configures the schema needed for the identity framework.
/// </summary>
/// <param name="builder">The builder being used to construct the model for this context.</param>
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
/// <summary>
/// Configures the conventions to be used for this context.
/// </summary>
/// <param name="configurationBuilder">The builder being used to configure conventions for this context.</param>
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);
configurationBuilder.Properties<string>().HaveMaxLength(450);
}
}
Explanation of Properties
-
ProductId
:
Represents the unique identifier of the associated product. It is nullable (string?
) to allow flexibility during object creation. -
Product
:
A navigation property linking theStock
entity to theProduct
entity, enabling easy access to related product details. -
Quantity
:
Stores the current quantity of the stock. -
Location
:
Represents the physical location (e.g., warehouse or store) where the stock is stored. It defaults to an empty string.
Step 2: Add Stock Features in the CleanAspire.Application Project
In this step, we will add features for managing Stock
in the CleanAspire.Application project. These features include queries and commands for handling stock-related operations, such as inventory queries and stock receiving/dispatching. Create the following structure under the Features
directory:
Stocks\Queries
Stocks\Commands
Query: StocksWithPaginationQuery
public record StocksWithPaginationQuery(string Keywords, int PageNumber = 1, int PageSize = 15, string OrderBy = "Id", string SortDirection = "Descending") : IFusionCacheRequest<PaginatedResult<StockDto>>
{
public IEnumerable<string>? Tags => new[] { "stocks" };
public string CacheKey => $"stockswithpagination_{Keywords}_{PageNumber}_{PageSize}_{OrderBy}_{SortDirection}";
}
public class StocksWithPaginationQueryHandler : IRequestHandler<StocksWithPaginationQuery, PaginatedResult<StockDto>>
{
private readonly IApplicationDbContext _context;
public StocksWithPaginationQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async ValueTask<PaginatedResult<StockDto>> Handle(StocksWithPaginationQuery request, CancellationToken cancellationToken)
{
var data = await _context.Stocks.OrderBy(request.OrderBy, request.SortDirection)
.ProjectToPaginatedDataAsync(
condition: x => x.Location.Contains(request.Keywords) || (x.Product != null && x.Product.Name.Contains(request.Keywords)),
pageNumber: request.PageNumber,
pageSize: request.PageSize,
mapperFunc: t => new StockDto
{
Id = t.Id,
ProductId = t.ProductId,
Product = t.ProductId != null ? new ProductDto
{
Category = (ProductCategoryDto)t.Product?.Category,
Currency = t.Product?.Currency,
Description = t.Product?.Description,
Id = t.Product?.Id,
Name = t.Product?.Name,
Price = t.Product?.Price ?? 0,
SKU = t.Product?.SKU,
UOM = t.Product?.UOM,
} : null,
Quantity = t.Quantity,
Location = t.Location
},
cancellationToken: cancellationToken);
return data;
}
}
Command: StockReceivingCommand
public record StockReceivingCommand : IFusionCacheRefreshRequest<Unit>, IRequiresValidation
{
public string ProductId { get; init; } = string.Empty;
public int Quantity { get; init; }
public string Location { get; init; } = string.Empty;
public IEnumerable<string>? Tags => new[] { "stocks" };
}
public class StockReceivingCommandHandler : IRequestHandler<StockReceivingCommand, Unit>
{
private readonly IApplicationDbContext _context;
public StockReceivingCommandHandler(IApplicationDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async ValueTask<Unit> Handle(StockReceivingCommand request, CancellationToken cancellationToken)
{
// Validate that the product exists
var product = await _context.Products
.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
if (product == null)
{
throw new KeyNotFoundException($"Product with Product ID '{request.ProductId}' was not found.");
}
// Check if the stock record already exists for the given ProductId and Location
var existingStock = await _context.Stocks
.FirstOrDefaultAsync(s => s.ProductId == request.ProductId && s.Location == request.Location, cancellationToken);
if (existingStock != null)
{
// If the stock record exists, update the quantity
existingStock.Quantity += request.Quantity;
_context.Stocks.Update(existingStock);
}
else
{
// If no stock record exists, create a new one
var newStockEntry = new Stock
{
ProductId = request.ProductId,
Location = request.Location,
Quantity = request.Quantity,
};
_context.Stocks.Add(newStockEntry);
}
// Save changes to the database
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
Command: StockDispatchingCommand
public record StockDispatchingCommand : IFusionCacheRefreshRequest<Unit>, IRequiresValidation
{
public string ProductId { get; init; } = string.Empty;
public int Quantity { get; init; }
public string Location { get; init; } = string.Empty;
public IEnumerable<string>? Tags => new[] { "stocks" };
}
public class StockDispatchingCommandHandler : IRequestHandler<StockDispatchingCommand, Unit>
{
private readonly IApplicationDbContext _context;
public StockDispatchingCommandHandler(IApplicationDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async ValueTask<Unit> Handle(StockDispatchingCommand request, CancellationToken cancellationToken)
{
// Validate that the product exists
var product = await _context.Products
.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
if (product == null)
{
throw new KeyNotFoundException($"Product with Product ID '{request.ProductId}' was not found.");
}
// Check if the stock record exists for the given ProductId and Location
var existingStock = await _context.Stocks
.FirstOrDefaultAsync(s => s.ProductId == request.ProductId && s.Location == request.Location, cancellationToken);
if (existingStock == null)
{
throw new KeyNotFoundException($"No stock record found for Product ID '{request.ProductId}' at Location '{request.Location}'.");
}
// Validate that the stock quantity is sufficient
if (existingStock.Quantity < request.Quantity)
{
throw new InvalidOperationException($"Insufficient stock quantity. Available: {existingStock.Quantity}, Requested: {request.Quantity}");
}
// Reduce the stock quantity
existingStock.Quantity -= request.Quantity;
// Update the stock record
_context.Stocks.Update(existingStock);
// Save changes to the database
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
Here's the continuation, explaining Step 3 in detail:
Step 3: Add and Register the Stock Endpoint in the CleanAspire.Api Project
In this step, we will implement the StockEndpointRegistrar
in the CleanAspire.Api project. This class defines and registers API endpoints for managing stocks, enabling interaction with the system via HTTP requests. Below is the implementation and an explanation of its intent:
Code Explanation:
public class StockEndpointRegistrar : IEndpointRegistrar
{
private readonly ILogger<StockEndpointRegistrar> _logger;
public StockEndpointRegistrar(ILogger<StockEndpointRegistrar> logger)
{
_logger = logger;
}
public void RegisterRoutes(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/stocks")
.WithTags("stocks")
.RequireAuthorization();
// Dispatch stock
group.MapPost("/dispatch", ([FromServices] IMediator mediator, [FromBody] StockDispatchingCommand command) =>
mediator.Send(command))
.Produces<Unit>(StatusCodes.Status200OK)
.ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Dispatch stock")
.WithDescription("Dispatches a specified quantity of stock from a location.");
// Receive stock
group.MapPost("/receive", ([FromServices] IMediator mediator, [FromBody] StockReceivingCommand command) =>
mediator.Send(command))
.Produces<Unit>(StatusCodes.Status200OK)
.ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Receive stock")
.WithDescription("Receives a specified quantity of stock into a location.");
// Get stocks with pagination
group.MapPost("/pagination", ([FromServices] IMediator mediator, [FromBody] StocksWithPaginationQuery query) =>
mediator.Send(query))
.Produces<PaginatedResult<StockDto>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Get stocks with pagination")
.WithDescription("Returns a paginated list of stocks based on search keywords, page size, and sorting options.");
}
}
Intent of the Code:
-
MapGroup
: Groups all stock-related endpoints under the/stocks
path, allowing for consistent and logical URL structure. -
Authorization Requirement: Ensures only authorized users can access these endpoints.
-
Endpoint Definitions:
- Dispatch Stock: Handles the removal of stock from a specified location.
- Receive Stock: Facilitates the addition of stock to a location.
- Pagination Query: Returns a paginated list of stock records based on search criteria and sorting preferences.
-
Response Types:
Status200OK
: Indicates a successful operation.Status422UnprocessableEntity
: Represents validation errors in input data.Status400BadRequest
andStatus500InternalServerError
: Handle general and server-side errors.
-
Documentation Features:
WithSummary
andWithDescription
: Provide concise summaries and detailed descriptions for API documentation, improving clarity and usability.
This implementation enables seamless interaction with the stock management system and adheres to best practices in designing RESTful APIs.
Step 4: Generate the Client Code Using Kiota in Visual Studio Code
In this step, we will generate the client code using Kiota in Visual Studio Code, enabling seamless interaction between front-end (or other external) applications and the CleanAspire API. By providing strongly typed methods and data models, the generated ClientApi reduces potential errors and simplifies development.
Below are the implementation steps and an explanation of the intent:
Implementation Steps
-
Build the CleanAspire.Api Project
- This will generate the
CleanAspire.Api.json
file, which Kiota uses to create the client code.
- This will generate the
-
Open Visual Studio Code and Load the
CleanAspire.Api.json
File- Navigate to the Kiota extension and select the
CleanAspire.Api.json
file as your API specification.
- Navigate to the Kiota extension and select the
-
Click “Generate Client Code”
- Specify the
Client
directory under theCleanAspire.ClientApp
project as the output path. - Before generating, delete any existing code in the
Client
directory to avoid conflicts.
- Specify the
Explanation of Its Intent
By generating the ClientApi, you create a straightforward way for your applications to call and consume the CleanAspire API. This client library:
- Provides strongly typed methods and data models, making API interaction more intuitive and less error-prone.
- Reduces boilerplate code, accelerating development and simplifying maintenance.
- Ensures consistency in how the API is accessed and utilized, promoting a robust and scalable integration.
Step 5: Add a Blazor Page for Stock Operations in the CleanAspire.ClientApp Project
In this step, we will add a new Blazor page to the CleanAspire.ClientApp
project to facilitate various stock operations, such as searching, receiving, and dispatching. By leveraging the MudDataGrid
component alongside the CleanAspire API, this page provides a seamless user experience for managing inventory. Below is the implementation and an explanation of its intent:
Implementation
@page "/stocks/index"
<PageTitle>@Title</PageTitle>
<MudDataGrid T="StockDto" Dense="true" Hover="true"
@ref="_table"
ServerData="@(ServerReload)"
MultiSelection="true"
SelectOnRowClick="false"
@bind-RowsPerPage="_defaultPageSize"
@bind-SelectedItems="_selectedItems"
@bind-SelectedItem="_currentDto">
<ToolBarContent>
<MudStack Row Spacing="0" Class="flex-grow-1" Justify="Justify.SpaceBetween">
<MudStack Row AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.Inventory" Size="Size.Large" />
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2">@L["Title"]</MudText>
<MudText Typo="Typo.body2">@L["Check product stock levels."]</MudText>
</MudStack>
</MudStack>
<MudStack Spacing="0" AlignItems="AlignItems.End">
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" OnClick="() => _table.ReloadServerData()" Disabled="@_loading">
<MudIcon Icon="@Icons.Material.Filled.Refresh" />
<MudText>@L["Refresh"]</MudText>
</MudButton>
<MudButton Size="Size.Small" OnClick="Receive">
<MudIcon Icon="@Icons.Material.Filled.Add" />
<MudText>@L["Receiving"]</MudText>
</MudButton>
</MudStack>
<MudStack Row Spacing="1">
<MudTextField T="string" ValueChanged="@(s => OnSearch(s))" Value="@_keywords" Placeholder="@L["Keywords"]" Adornment="Adornment.End"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Small">
</MudTextField>
</MudStack>
</MudStack>
</MudStack>
</ToolBarContent>
<Columns>
<TemplateColumn HeaderStyle="width:60px" Title="@L["Actions"]" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.LocalShipping" OnClick="@(x => Dispatch(context.Item))" />
</CellTemplate>
</TemplateColumn>
<PropertyColumn Title="@L["SKU"]" Property="x => x.ProductId">
<CellTemplate>
@context.Item.Product?.Sku
</CellTemplate>
</PropertyColumn>
<PropertyColumn Title="@L["Product Name"]" Property="x => x.ProductId" Sortable="false">
<CellTemplate>
<MudText Typo="Typo.body1">@context.Item.Product?.Name</MudText>
<MudText Typo="Typo.body1">@context.Item.Product?.Description</MudText>
</CellTemplate>
</PropertyColumn>
<PropertyColumn Title="@L["Location"]" Property="x => x.Location" />
<PropertyColumn Title="@L["Quantity"]" Property="x => x.Quantity" Format="#,#" AggregateDefinition="_qtyAggregation" />
<PropertyColumn Title="@L["UOM"]" Property="x => x.ProductId" Sortable="false">
<CellTemplate>
@context.Item.Product?.Uom
</CellTemplate>
</PropertyColumn>
<PropertyColumn Title="@L["Amount"]" Property="x => x.Product" Sortable="false">
<CellTemplate>
@($"{((context.Item.Quantity ?? 0) * (context.Item.Product?.Price ?? 0)).ToString("#,#")} {context.Item.Product?.Currency}")
</CellTemplate>
</PropertyColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="StockDto" />
</PagerContent>
</MudDataGrid>
@code {
public string Title = "Stock Inquiry";
private HashSet<StockDto> _selectedItems = new();
private StockDto _currentDto = new();
private MudDataGrid<StockDto> _table = default!;
private int _defaultPageSize = 10;
private string _keywords = string.Empty;
private bool _loading = false;
private readonly string[] tags = new[] { "stocks" };
private readonly TimeSpan timeSpan = TimeSpan.FromSeconds(30);
private async Task<GridData<StockDto>> ServerReload(GridState<StockDto> state)
{
try
{
_loading = true;
var query = new StocksWithPaginationQuery();
query.PageNumber = state.Page;
query.PageSize = state.PageSize;
query.Keywords = _keywords;
query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id";
query.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true
? SortDirection.Descending.ToString()
: SortDirection.Ascending.ToString();
var cacheKey = $"_{query.Keywords}_{query.PageSize}_{query.PageNumber}_{query.OrderBy}_{query.SortDirection}";
var result = await ApiClientServiceProxy.QueryAsync(
cacheKey,
() => ApiClient.Stocks.Pagination.PostAsync(query),
tags,
timeSpan
);
return new GridData<StockDto> { TotalItems = (int)result.TotalItems, Items = result.Items };
}
finally
{
_loading = false;
}
}
AggregateDefinition<StockDto> _qtyAggregation = new AggregateDefinition<StockDto>
{
Type = AggregateType.Sum,
NumberFormat = "#,#",
DisplayFormat = "Total quantity is {value}"
};
private async Task OnSearch(string text)
{
_selectedItems = new HashSet<StockDto>();
_keywords = text;
await _table.ReloadServerData();
}
private async Task Receive()
{
var parameters = new DialogParameters<StockDialog>
{
{ x => x.Inbound, true }
};
await DialogServiceHelper.ShowDialogAsync<StockDialog>(
L["Stock receiving"],
parameters,
new DialogOptions() { MaxWidth = MaxWidth.Small },
async (state) =>
{
if (state is not null && !state.Canceled)
{
await ApiClientServiceProxy.ClearCache(tags);
await _table.ReloadServerData();
_selectedItems = new();
}
}
);
}
private async Task Dispatch(StockDto item)
{
var parameters = new DialogParameters<StockDialog>
{
{ x => x.Inbound, false },
{ x => x.ProductId, item.Product?.Id },
{ x => x.model, new StockDialog.Model { Location = item.Location, Quantity = item.Quantity } }
};
await DialogServiceHelper.ShowDialogAsync<StockDialog>(
L["Stock dispatching"],
parameters,
new DialogOptions() { MaxWidth = MaxWidth.Small },
async (state) =>
{
if (state is not null && !state.Canceled)
{
await ApiClientServiceProxy.ClearCache(tags);
await _table.ReloadServerData();
_selectedItems = new();
}
}
);
}
}
Below is the dialog component that the Receive
and Dispatch
methods open when creating or updating stock records:
@using System.ComponentModel.DataAnnotations
@using CleanAspire.ClientApp.Components.Autocompletes
<MudDialog>
<DialogContent>
<MudForm @ref="editForm">
<div class="d-flex flex-column gap-2">
<ProductAutocomplete T="ProductDto" @bind-Value="@model.Product" DefaultProductId="@ProductId" Label="Product" Required="true" ErrorText="@L["Product is required."]" />
<MudTextField T="string" Label="Location" For="@(() => model.Location)" @bind-Value="model.Location" Required="true" ErrorText="@L["Location id is required."]" />
<MudNumericField T="int?" Label="Quantity" For="@(() => model.Quantity)" @bind-Value="model.Quantity" Validation="ValidateQuantity" Required="true" ErrorText="@L["Quantity must be greater than 0."]" />
</div>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">@L["Cancel"]</MudButton>
<MudButton OnClick="Submit" Disabled="@_saving">@L["Save"]</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public string? ProductId { get; set; }
[Parameter] public bool Inbound { get; set; }
[Parameter] public Model model { get; set; } = new Model();
private MudForm editForm = default!;
public string Localtion { get; set; } = string.Empty;
private bool _saving = false;
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
await editForm.Validate(); // Validate manually before submitting.
if (editForm.IsValid)
{
_saving = true;
if (Inbound)
{
var result = await ApiClientServiceProxy.ExecuteAsync(
() => ApiClient.Stocks.Receive.PostAsync(new StockReceivingCommand
{
ProductId = model.Product.Id,
Location = model.Location,
Quantity = model.Quantity
})
);
result.Switch(
ok =>
{
Snackbar.Add(L["Stock received successfully."], Severity.Success);
},
invalid =>
{
Snackbar.Add(L[invalid.Detail ?? "Failed validation"], Severity.Error);
},
error =>
{
Snackbar.Add(L["Stock receiving failed. Please try again."], Severity.Error);
}
);
}
else
{
var result = await ApiClientServiceProxy.ExecuteAsync(
() => ApiClient.Stocks.Dispatch.PostAsync(new StockDispatchingCommand
{
ProductId = model.Product.Id,
Location = model.Location,
Quantity = model.Quantity
})
);
result.Switch(
ok =>
{
Snackbar.Add(L["Stock dispatched successfully."], Severity.Success);
},
invalid =>
{
Snackbar.Add(L[invalid.Detail ?? "Failed validation"], Severity.Error);
},
error =>
{
Snackbar.Add(L["Stock dispatching failed. Please try again."], Severity.Error);
}
);
}
MudDialog.Close(DialogResult.Ok(model));
_saving = false;
}
}
private IEnumerable<string> ValidateQuantity(int? value)
{
if (!value.HasValue || value <= 0)
{
yield return "Quantity must be greater than 0.";
}
}
public class Model
{
[Required(ErrorMessage = "Product id is required.")]
public ProductDto? Product { get; set; }
[Required(ErrorMessage = "Location id is required.")]
public string Location { get; set; } = string.Empty;
public int? Quantity { get; set; }
}
}
Explanation of Its Intent
By adding this Blazor page, you empower users to perform essential stock operations directly from the front-end. Specifically, they can:
- Browse and Search Stock: The
MudDataGrid
displays stock records with built-in pagination and sorting, while the search bar filters records based on keywords. - Receive Stock: The “Receiving” button opens a dialog to add incoming stock.
- Dispatch Stock: The “LocalShipping” icon in each row opens a dialog to dispatch stock.
- Keep Data in Sync: Upon successful receive or dispatch, the cache is cleared, and the DataGrid reloads to display updated information.
This approach streamlines inventory management in a user-friendly manner, leveraging the underlying CleanAspire API for data retrieval and updates.