Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to Create a New Object in a CRUD Application: A Step-by-Step Guide #34

Closed
neozhu opened this issue Jan 6, 2025 · 0 comments · Fixed by #36
Closed

How to Create a New Object in a CRUD Application: A Step-by-Step Guide #34

neozhu opened this issue Jan 6, 2025 · 0 comments · Fixed by #36
Assignees
Labels
documentation Improvements or additions to documentation

Comments

@neozhu
Copy link
Owner

neozhu commented Jan 6, 2025

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

  1. ProductId:
    Represents the unique identifier of the associated product. It is nullable (string?) to allow flexibility during object creation.

  2. Product:
    A navigation property linking the Stock entity to the Product entity, enabling easy access to related product details.

  3. Quantity:
    Stores the current quantity of the stock.

  4. 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:

  1. MapGroup: Groups all stock-related endpoints under the /stocks path, allowing for consistent and logical URL structure.

  2. Authorization Requirement: Ensures only authorized users can access these endpoints.

  3. 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.
  4. Response Types:

    • Status200OK: Indicates a successful operation.
    • Status422UnprocessableEntity: Represents validation errors in input data.
    • Status400BadRequest and Status500InternalServerError: Handle general and server-side errors.
  5. Documentation Features:

    • WithSummary and WithDescription: 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

  1. Build the CleanAspire.Api Project

    • This will generate the CleanAspire.Api.json file, which Kiota uses to create the client code.
  2. 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.
  3. Click “Generate Client Code”

    • Specify the Client directory under the CleanAspire.ClientApp project as the output path.
    • Before generating, delete any existing code in the Client directory to avoid conflicts.

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:

  1. Browse and Search Stock: The MudDataGrid displays stock records with built-in pagination and sorting, while the search bar filters records based on keywords.
  2. Receive Stock: The “Receiving” button opens a dialog to add incoming stock.
  3. Dispatch Stock: The “LocalShipping” icon in each row opens a dialog to dispatch stock.
  4. 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.

@neozhu neozhu added the documentation Improvements or additions to documentation label Jan 6, 2025
@neozhu neozhu self-assigned this Jan 6, 2025
@neozhu neozhu pinned this issue Jan 7, 2025
@neozhu neozhu linked a pull request Jan 7, 2025 that will close this issue
@neozhu neozhu closed this as completed in #36 Jan 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant